4569 lines
173 KiB
JavaScript
4569 lines
173 KiB
JavaScript
(() => {
|
||
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
|
||
// === Custom modal functions ===
|
||
function customAlert(msg) {
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "custom-modal-overlay";
|
||
const box = document.createElement("div");
|
||
box.className = "custom-modal-box";
|
||
const text = document.createElement("div");
|
||
text.textContent = msg;
|
||
text.style.marginBottom = "16px";
|
||
const btn = document.createElement("button");
|
||
btn.className = "btn-ok";
|
||
btn.textContent = "OK";
|
||
btn.onclick = () => overlay.remove();
|
||
box.appendChild(text);
|
||
box.appendChild(btn);
|
||
overlay.appendChild(box);
|
||
document.querySelector("#game").appendChild(overlay);
|
||
}
|
||
function customConfirm(msg, onYes) {
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "custom-modal-overlay";
|
||
const box = document.createElement("div");
|
||
box.className = "custom-modal-box";
|
||
const text = document.createElement("div");
|
||
text.textContent = msg;
|
||
text.style.marginBottom = "16px";
|
||
const btns = document.createElement("div");
|
||
btns.className = "modal-btns";
|
||
const yesBtn = document.createElement("button");
|
||
yesBtn.className = "btn-yes";
|
||
yesBtn.textContent = "Да";
|
||
yesBtn.onclick = () => { overlay.remove(); onYes(); };
|
||
const noBtn = document.createElement("button");
|
||
noBtn.className = "btn-no";
|
||
noBtn.textContent = "Отмена";
|
||
noBtn.onclick = () => overlay.remove();
|
||
btns.appendChild(yesBtn);
|
||
btns.appendChild(noBtn);
|
||
box.appendChild(text);
|
||
box.appendChild(btns);
|
||
overlay.appendChild(box);
|
||
document.querySelector("#game").appendChild(overlay);
|
||
}
|
||
// Возможность переопределить сервер через query string
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
|
||
const TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; // Имя бота для share-ссылки
|
||
const TELEGRAM_APP_SHORT_NAME = 'minegrechka'; // Короткое имя Mini App
|
||
|
||
// Защита от mixed content
|
||
if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) {
|
||
console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
|
||
alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
|
||
}
|
||
|
||
// ==================== WORLD ID И ИГРОКА ====================
|
||
let worldId = null;
|
||
let playerName = localStorage.getItem('minegrechka_playerName') || null;
|
||
|
||
// Запрашиваем имя игрока, если его нет
|
||
if (!playerName) {
|
||
playerName = prompt('Введите ваше имя для игры:') || 'Игрок';
|
||
localStorage.setItem('minegrechka_playerName', playerName);
|
||
console.log('Player name set:', playerName);
|
||
}
|
||
|
||
// Берём worldId из URL или генерируем новый
|
||
console.log('Current URL:', window.location.href);
|
||
const worldParam = urlParams.get('world');
|
||
console.log('world param:', worldParam);
|
||
|
||
// Проверяем на null, undefined или пустую строку
|
||
worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null;
|
||
|
||
console.log('worldId after params:', worldId, 'type:', typeof worldId);
|
||
|
||
// Если worldId отсутствует - генерируем новый и записываем в URL
|
||
if (!worldId) {
|
||
worldId = Math.random().toString(36).substring(2, 10);
|
||
console.log('Generated worldId:', worldId);
|
||
|
||
try {
|
||
const newUrl = new URL(window.location.href);
|
||
newUrl.searchParams.set('world', worldId);
|
||
const newUrlString = newUrl.toString();
|
||
console.log('New URL to set:', newUrlString);
|
||
|
||
// Проверяем, поддерживается ли history API
|
||
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
|
||
window.history.replaceState(null, '', newUrlString);
|
||
console.log('URL after replaceState:', window.location.href);
|
||
console.log('URL after replaceState (direct check):', window.location.search);
|
||
} else {
|
||
console.error('History API not supported!');
|
||
}
|
||
} catch (e) {
|
||
console.error('Error updating URL:', e);
|
||
}
|
||
|
||
console.log('Generated new worldId for browser:', worldId);
|
||
}
|
||
|
||
console.log('Final worldId:', worldId, 'Player name:', playerName);
|
||
|
||
console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`);
|
||
|
||
// Обработчик клика на worldId для копирования ссылки
|
||
document.getElementById('worldId').onclick = () => {
|
||
const shareUrl = new URL(window.location.href);
|
||
shareUrl.searchParams.set('world', worldId);
|
||
const shareUrlString = shareUrl.toString();
|
||
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(shareUrlString).then(() => {
|
||
alert('Ссылка скопирована!');
|
||
}).catch(() => {
|
||
alert('Ссылка на мир:\n' + shareUrlString);
|
||
});
|
||
} else {
|
||
alert('Ссылка на мир:\n' + shareUrlString);
|
||
}
|
||
};
|
||
|
||
// ==================== SOCKET.IO КЛИЕНТ ====================
|
||
let socket = null;
|
||
let isMultiplayer = false; // Флаг для мультиплеерного режима
|
||
const otherPlayers = new Map(); // socket_id -> {x, y, color}
|
||
const serverMobs = new Map(); // id -> mob (server-spawned, client-authoritative physics)
|
||
|
||
// Helper to get all mobs (local + server-spawned in MP)
|
||
function getAllMobs() {
|
||
return isMultiplayer ? mobs.concat(Array.from(serverMobs.values())) : mobs;
|
||
}
|
||
|
||
// Create a client-side mob object from server spawn data with correct properties matching client constructors
|
||
function createMobFromServer(data) {
|
||
const kindProps = {
|
||
zombie: { w: 34, h: 50, hp: 4, speed: 80 + Math.random() * 40, hostile: true, fuse: 0, shootCooldown: 2 },
|
||
creeper: { w: 34, h: 50, hp: 4, speed: 60 + Math.random() * 30, hostile: true, fuse: 3.2, shootCooldown: 2 },
|
||
skeleton: { w: 34, h: 50, hp: 4, speed: 70 + Math.random() * 30, hostile: true, fuse: 0, shootCooldown: 0 },
|
||
pig: { w: 34, h: 24, hp: 2, speed: 40, hostile: false, fuse: 0, shootCooldown: 2 },
|
||
chicken: { w: 26, h: 22, hp: 1, speed: 55, hostile: false, fuse: 0, shootCooldown: 2 },
|
||
scorpion: { w: 26, h: 26, hp: 3, speed: 90, hostile: true, fuse: 0, shootCooldown: 0, biome:'desert' },
|
||
polar_bear:{ w: 40, h: 34, hp: 8, speed: 50, hostile: false, fuse: 0, shootCooldown: 2, biome:'tundra' },
|
||
slime: { w: 24, h: 24, hp: 2, speed: 30, hostile: true, fuse: 0, shootCooldown: 2, biome:'swamp' },
|
||
eagle: { w: 30, h: 22, hp: 3, speed: 120, hostile: true, fuse: 0, shootCooldown: 0, biome:'mountains' }
|
||
};
|
||
const props = kindProps[data.kind] || kindProps['pig']; // fallback
|
||
return {
|
||
id: data.id,
|
||
kind: data.kind,
|
||
x: data.x,
|
||
y: data.y,
|
||
w: props.w,
|
||
h: props.h,
|
||
hp: data.hp || props.hp,
|
||
maxHp: data.maxHp || data.hp || props.hp,
|
||
speed: props.speed,
|
||
hostile: props.hostile,
|
||
vx: 0,
|
||
vy: 0,
|
||
grounded: false,
|
||
inWater: false,
|
||
aiT: 0,
|
||
dir: data.dir || 1,
|
||
dead: false,
|
||
fuse: props.fuse,
|
||
shootCooldown: props.shootCooldown
|
||
};
|
||
}
|
||
let mySocketId = null;
|
||
|
||
// Throttle для отправки позиции (10-20 раз в секунду)
|
||
let lastMoveSendTime = 0;
|
||
const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
|
||
let lastSentX = 0, lastSentY = 0;
|
||
|
||
function initSocket() {
|
||
try {
|
||
socket = io(SERVER_URL, {
|
||
path: '/socket.io/',
|
||
transports: ['websocket', 'polling']
|
||
});
|
||
|
||
socket.on('connect', () => {
|
||
console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`);
|
||
mySocketId = socket.id;
|
||
isMultiplayer = true;
|
||
|
||
// Присоединяемся к миру
|
||
socket.emit('join_world', { world_id: worldId, player_name: playerName });
|
||
|
||
// Показываем в UI
|
||
worldIdEl.textContent = worldId;
|
||
// XP/Level display
|
||
const lvXpNext = xpForLevel(player.level + 1);
|
||
const lvXpCur = xpForLevel(player.level);
|
||
const xpInLevel = player.xp - lvXpCur;
|
||
const xpNeeded = lvXpNext - lvXpCur;
|
||
document.getElementById('xplevel').textContent = player.level;
|
||
document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded;
|
||
multiplayerStatus.style.display = 'block';
|
||
});
|
||
|
||
socket.on('connect_error', (error) => {
|
||
console.error('Socket connection error:', error);
|
||
isMultiplayer = false;
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
console.log('Disconnected from server');
|
||
isMultiplayer = false;
|
||
otherPlayers.clear();
|
||
multiplayerStatus.style.display = 'none';
|
||
});
|
||
|
||
// Обработка world_state
|
||
socket.on('world_state', (data) => {
|
||
console.log('Received world_state:', data);
|
||
|
||
// Устанавливаем seed и перегенерируем мир если он изменился
|
||
if (data.seed !== undefined && data.seed !== worldSeed) {
|
||
const oldSeed = worldSeed;
|
||
worldSeed = data.seed;
|
||
console.log('World seed changed from', oldSeed, 'to', worldSeed);
|
||
|
||
// Очищаем и перегенерируем мир с новым seed
|
||
generated.clear();
|
||
grid.clear();
|
||
blocks.length = 0;
|
||
placedBlocks = [];
|
||
removedBlocks = [];
|
||
console.log('World regenerated with new seed:', worldSeed);
|
||
}
|
||
|
||
// Применяем блоки — сохраняем в serverOverrides для применения после genColumn
|
||
if (data.blocks && Array.isArray(data.blocks)) {
|
||
for (const block of data.blocks) {
|
||
const key = k(block.gx, block.gy);
|
||
serverOverrides.set(key, { op: block.op, t: block.t });
|
||
// Также пробуем применить сразу (если колонна уже сгенерирована)
|
||
if (block.op === 'set') {
|
||
setBlock(block.gx, block.gy, block.t, false);
|
||
} else if (block.op === 'remove') {
|
||
removeBlock(block.gx, block.gy);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Устанавливаем время
|
||
if (data.time !== undefined) {
|
||
worldTime = data.time;
|
||
isNightTime = worldTime > 0.5;
|
||
}
|
||
|
||
// Всегда считаем spawnPoint на клиенте по той же формуле что и surfaceGyAt
|
||
// Это гарантирует совпадение с terrain generation
|
||
{
|
||
const startGX = 6;
|
||
// Генерируем колонну и соседние для безопасного спавна
|
||
for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
|
||
const surfaceY = surfaceGyAt(startGX);
|
||
// Ищем ближайшую небудущую позицию сверху вниз от поверхности
|
||
let safeGY = surfaceY - 1;
|
||
// Проверяем что над поверхностью воздух (не в воде)
|
||
const aboveBlock = getBlock(startGX, surfaceY - 1);
|
||
if (aboveBlock && aboveBlock.t === 'water') {
|
||
// Если в воде — ищем поверхность выше уровня моря
|
||
for (let gy = SEA_GY - 1; gy >= 0; gy--) {
|
||
const b = getBlock(startGX, gy);
|
||
if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
|
||
safeGY = gy - 1;
|
||
break;
|
||
}
|
||
}
|
||
spawnPoint.x = startGX * TILE;
|
||
spawnPoint.y = safeGY * TILE;
|
||
console.log('Client-side spawn point:', spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY);
|
||
}
|
||
|
||
// Устанавливаем игрока в точку спавна
|
||
player.x = spawnPoint.x;
|
||
player.y = spawnPoint.y;
|
||
player.vx = 0;
|
||
player.vy = 0;
|
||
player.fallStartY = player.y;
|
||
console.log('Player moved to spawn point:', player.x, player.y);
|
||
|
||
// Устанавливаем HP на 100% при каждом подключении к миру
|
||
player.hp = 100;
|
||
player.hunger = 100;
|
||
player.o2 = 100;
|
||
player.invuln = 0;
|
||
console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
|
||
|
||
// Обновляем список игроков
|
||
if (data.players && Array.isArray(data.players)) {
|
||
otherPlayers.clear();
|
||
for (const p of data.players) {
|
||
if (p.socket_id !== mySocketId) {
|
||
otherPlayers.set(p.socket_id, {
|
||
x: p.x,
|
||
y: p.y,
|
||
color: getRandomPlayerColor(p.socket_id),
|
||
name: p.player_name || 'Игрок'
|
||
});
|
||
}
|
||
}
|
||
// Обновляем счётчик игроков
|
||
playerCountEl.textContent = data.players.length;
|
||
}
|
||
// Server mobs — client-authoritative: create with full client-side properties
|
||
if (data.mobs && Array.isArray(data.mobs)) {
|
||
serverMobs.clear();
|
||
for (const m of data.mobs) {
|
||
const sm = createMobFromServer(m);
|
||
serverMobs.set(m.id, sm);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Игрок присоединился
|
||
socket.on('player_joined', (data) => {
|
||
console.log('Player joined:', data.socket_id);
|
||
if (data.socket_id !== mySocketId) {
|
||
// Генерируем безопасную позицию для нового игрока
|
||
const spawnGX = 6;
|
||
genColumn(spawnGX);
|
||
const surfaceY = surfaceGyAt(spawnGX);
|
||
const safeSpawnX = spawnGX * TILE;
|
||
const safeSpawnY = (surfaceY - 1) * TILE;
|
||
|
||
otherPlayers.set(data.socket_id, {
|
||
x: safeSpawnX,
|
||
y: safeSpawnY,
|
||
color: getRandomPlayerColor(data.socket_id),
|
||
name: data.player_name || 'Игрок'
|
||
});
|
||
addChatMessage('Система', `Игрок присоединился`);
|
||
// Обновляем видимость кнопки сохранения
|
||
updateSaveButtonVisibility();
|
||
}
|
||
});
|
||
|
||
// Игрок переместился
|
||
socket.on('player_moved', (data) => {
|
||
if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) {
|
||
const p = otherPlayers.get(data.socket_id);
|
||
p.x = data.x;
|
||
p.y = data.y;
|
||
// Обновляем имя, если оно пришло
|
||
if (data.player_name) {
|
||
p.name = data.player_name;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Игрок покинул
|
||
socket.on('player_left', (data) => {
|
||
console.log('Player left:', data.socket_id);
|
||
otherPlayers.delete(data.socket_id);
|
||
addChatMessage('Система', `Игрок покинул игру`);
|
||
// Обновляем видимость кнопки сохранения
|
||
updateSaveButtonVisibility();
|
||
});
|
||
|
||
// === MOB SYNC (multiplayer) ===
|
||
|
||
socket.on('mob_spawned', (data) => {
|
||
const sm = createMobFromServer(data);
|
||
serverMobs.set(data.id, sm);
|
||
});
|
||
|
||
socket.on('mob_positions', (arr) => {
|
||
// Client-authoritative: ignore server positions, mobAI handles physics locally.
|
||
// Only update HP/fuse/direction for consistency (e.g. if another client hurt the mob).
|
||
for (const u of arr) {
|
||
const sm = serverMobs.get(u.id);
|
||
if (sm) { sm.hp = u.hp; sm.fuse = u.fuse != null ? u.fuse : sm.fuse; }
|
||
}
|
||
});
|
||
|
||
socket.on('mob_despawned', (data) => { serverMobs.delete(data.id); });
|
||
|
||
socket.on('mob_died', (data) => {
|
||
const sm = serverMobs.get(data.id);
|
||
if (sm && data.killer === mySocketId) {
|
||
// Give loot to the killer
|
||
if (sm.kind === 'chicken') playSound('hurt_chicken');
|
||
spawnDrops(sm.x, sm.y, sm.kind);
|
||
grantXP(getMobXP(sm.kind));
|
||
rebuildHotbar();
|
||
}
|
||
serverMobs.delete(data.id);
|
||
});
|
||
|
||
socket.on('mob_hurt_ack', (data) => {
|
||
const sm = serverMobs.get(data.id);
|
||
if (sm) sm.hp = data.hp;
|
||
});
|
||
|
||
socket.on('mob_explode', (data) => {
|
||
explodeAt(data.gx, data.gy);
|
||
serverMobs.delete(data.id);
|
||
});
|
||
|
||
socket.on('mob_shoot', (data) => {
|
||
projectiles.push({
|
||
x: data.x, y: data.y, vx: data.vx, vy: data.vy,
|
||
dmg: data.dmg, owner: 'mob', life: data.life
|
||
});
|
||
});
|
||
|
||
// Блок изменён
|
||
socket.on('block_changed', (data) => {
|
||
const key = k(data.gx, data.gy);
|
||
serverOverrides.set(key, { op: data.op, t: data.t });
|
||
if (data.op === 'set') {
|
||
setBlock(data.gx, data.gy, data.t, false);
|
||
} else if (data.op === 'remove') {
|
||
removeBlock(data.gx, data.gy);
|
||
}
|
||
});
|
||
|
||
// Сообщение в чат
|
||
socket.on('chat_message', (data) => {
|
||
const senderName = data.socket_id === mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
|
||
addChatMessage(senderName, data.message);
|
||
});
|
||
|
||
// Обновление времени
|
||
socket.on('time_update', (data) => {
|
||
if (data.time !== undefined) {
|
||
worldTime = data.time;
|
||
isNightTime = worldTime > 0.5;
|
||
}
|
||
});
|
||
|
||
} catch (e) {
|
||
console.error('Error initializing socket:', e);
|
||
isMultiplayer = false;
|
||
}
|
||
}
|
||
|
||
// Генерация случайного цвета для игрока на основе socket_id
|
||
function getRandomPlayerColor(socketId) {
|
||
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
|
||
let hash = 0;
|
||
for (let i = 0; i < socketId.length; i++) {
|
||
hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
|
||
hash = hash & hash;
|
||
}
|
||
return colors[Math.abs(hash) % colors.length];
|
||
}
|
||
|
||
// Отправка позиции игрока (с throttle)
|
||
function sendPlayerPosition() {
|
||
if (!isMultiplayer || !socket || !socket.connected) return;
|
||
|
||
const now = performance.now() / 1000;
|
||
if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
|
||
|
||
// Отправляем только если позиция изменилась
|
||
const dx = Math.abs(player.x - lastSentX);
|
||
const dy = Math.abs(player.y - lastSentY);
|
||
if (dx < 1 && dy < 1) return;
|
||
|
||
lastMoveSendTime = now;
|
||
lastSentX = player.x;
|
||
lastSentY = player.y;
|
||
|
||
socket.emit('player_move', { x: player.x, y: player.y, player_name: playerName });
|
||
}
|
||
|
||
// Отправка изменения блока
|
||
function sendBlockChange(gx, gy, t, op) {
|
||
if (!isMultiplayer || !socket || !socket.connected) return;
|
||
|
||
socket.emit('block_change', { gx, gy, t, op });
|
||
}
|
||
|
||
// ==================== ЧАТ ====================
|
||
const chatMessages = [];
|
||
const MAX_CHAT_MESSAGES = 20;
|
||
|
||
function addChatMessage(sender, message) {
|
||
const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||
chatMessages.push({ sender, message, time });
|
||
if (chatMessages.length > MAX_CHAT_MESSAGES) {
|
||
chatMessages.shift();
|
||
}
|
||
renderChatMessages();
|
||
}
|
||
|
||
function renderChatMessages() {
|
||
const chatMessagesEl = document.getElementById('chatMessages');
|
||
if (!chatMessagesEl) return;
|
||
|
||
chatMessagesEl.innerHTML = chatMessages.map(m =>
|
||
`<div style="margin-bottom:4px;"><span style="color:#aaa;font-size:11px;">${m.time}</span> <strong style="color:${m.sender === 'Система' ? '#f39c12' : '#3498db'};">${m.sender}:</strong> ${m.message}</div>`
|
||
).join('');
|
||
|
||
// Прокручиваем вниз
|
||
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
|
||
}
|
||
|
||
function sendChatMessage(message) {
|
||
if (!message || message.trim() === '') return;
|
||
|
||
if (isMultiplayer && socket && socket.connected) {
|
||
socket.emit('chat_message', { message: message.trim() });
|
||
} else {
|
||
addChatMessage('Вы', message.trim());
|
||
}
|
||
}
|
||
|
||
// ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
|
||
function shareWorld() {
|
||
const shareUrl = new URL(window.location.href);
|
||
shareUrl.searchParams.set('world', worldId);
|
||
const shareUrlString = shareUrl.toString();
|
||
|
||
// Копируем в буфер обмена
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(shareUrlString).then(() => {
|
||
alert('Ссылка скопирована!');
|
||
}).catch(() => {
|
||
alert('Ссылка на мир:\n' + shareUrlString);
|
||
});
|
||
} else {
|
||
alert('Ссылка на мир:\n' + shareUrlString);
|
||
}
|
||
}
|
||
|
||
// ==================== ИНИЦИАЛИЗАЦИЯ UI ====================
|
||
let chatOpen = false;
|
||
|
||
document.getElementById('chatToggle').onclick = () => {
|
||
playSound('click');
|
||
chatOpen = !chatOpen;
|
||
document.getElementById('chatPanel').style.display = chatOpen ? 'block' : 'none';
|
||
if (chatOpen) {
|
||
document.getElementById('chatInput').focus();
|
||
}
|
||
};
|
||
|
||
document.getElementById('chatClose').onclick = () => {
|
||
playSound('click');
|
||
chatOpen = false;
|
||
document.getElementById('chatPanel').style.display = 'none';
|
||
};
|
||
|
||
document.getElementById('chatSend').onclick = () => {
|
||
const input = document.getElementById('chatInput');
|
||
sendChatMessage(input.value);
|
||
input.value = '';
|
||
};
|
||
|
||
document.getElementById('chatInput').addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
sendChatMessage(e.target.value);
|
||
e.target.value = '';
|
||
}
|
||
});
|
||
|
||
// ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ====================
|
||
// Инициализируем socket
|
||
initSocket();
|
||
|
||
// ==================== ЗВУКОВОЙ ДВИЖОК ====================
|
||
const sounds = {};
|
||
function loadSound(id, src) {
|
||
const audio = new Audio();
|
||
audio.src = src;
|
||
audio.volume = 0.3;
|
||
sounds[id] = audio;
|
||
}
|
||
|
||
// Загрузка звуков
|
||
loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
|
||
loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
|
||
loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
|
||
loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
|
||
loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
|
||
loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
|
||
loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
|
||
loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
|
||
loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
|
||
loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
|
||
loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
|
||
loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
|
||
loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
|
||
loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
|
||
loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
|
||
loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
|
||
loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
|
||
|
||
function playSound(id) {
|
||
if(sounds[id]) {
|
||
sounds[id].currentTime = 0;
|
||
sounds[id].play().catch(e => console.error('Sound error:', e));
|
||
}
|
||
}
|
||
|
||
// Играем звук при прыжке
|
||
const gameEl = document.getElementById('game');
|
||
const canvas = document.getElementById('c');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// offscreen light map (не вставляем в DOM)
|
||
const lightC = document.createElement('canvas');
|
||
const lightCtx = lightC.getContext('2d');
|
||
|
||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||
let W=0, H=0;
|
||
|
||
const TILE = 40;
|
||
|
||
// Мир
|
||
const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
|
||
const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
|
||
const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
|
||
|
||
const heroImg = new Image();
|
||
heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
|
||
|
||
// Состояние инвентаря
|
||
let showFullInventory = false;
|
||
let recentItems = []; // Последние 5 выбранных предметов
|
||
|
||
const BLOCKS = {
|
||
air: { n:'Воздух', solid:false },
|
||
grass: { n:'Трава', c:'#7cfc00', solid:true },
|
||
dirt: { n:'Грязь', c:'#8b4513', solid:true },
|
||
stone: { n:'Камень', c:'#7f8c8d', solid:true },
|
||
sand: { n:'Песок', c:'#f4d06f', solid:true },
|
||
gravel: { n:'Гравий', c:'#95a5a6', solid:true },
|
||
clay: { n:'Глина', c:'#74b9ff', solid:true },
|
||
wood: { n:'Дерево', c:'#d35400', solid:true },
|
||
planks: { n:'Доски', c:'#e67e22', solid:true },
|
||
ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true },
|
||
leaves: { n:'Листва', c:'#2ecc71', solid:true },
|
||
glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 },
|
||
water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true },
|
||
coal: { n:'Уголь', c:'#2c3e50', solid:true },
|
||
copper_ore:{ n:'Медь', c:'#e17055', solid:true },
|
||
iron_ore: { n:'Железо', c:'#dcdde1', solid:true },
|
||
iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 },
|
||
gold_ore: { n:'Золото', c:'#f1c40f', solid:true },
|
||
diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true },
|
||
brick: { n:'Кирпич', c:'#c0392b', solid:true },
|
||
tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true },
|
||
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280 },
|
||
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220 },
|
||
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
|
||
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
|
||
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
|
||
boat: { n:'Лодка', c:'#8B4513', solid:false },
|
||
furnace: { n:'Печь', c:'#696969', solid:true, smelting:true },
|
||
// === BIOME BLOCKS ===
|
||
snow: { n:'Снег', c:'#ecf0f1', solid:true },
|
||
ice: { n:'Лёд', c:'#74b9ff', solid:true, slip:true },
|
||
cactus: { n:'Кактус', c:'#27ae60', solid:true, hurt:true },
|
||
mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true },
|
||
moss: { n:'Мох', c:'#0a6640', solid:true },
|
||
swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true },
|
||
farmland: { n:'Грядка', c:'#8B6914', solid:true, farmable:true },
|
||
dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true },
|
||
spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true },
|
||
// === CROP STAGES ===
|
||
wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'wheat_stage1' },
|
||
wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'wheat_stage2' },
|
||
wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'wheat_stage3' },
|
||
wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, harvestable:true, harvestItem:'wheat', harvestQty:2 },
|
||
carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'carrot_stage1' },
|
||
carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'carrot_stage2' },
|
||
carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'carrot_stage3' },
|
||
carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, harvestable:true, harvestItem:'carrot', harvestQty:3 },
|
||
potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'potato_stage1' },
|
||
potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'potato_stage2' },
|
||
potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'potato_stage3' },
|
||
potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, harvestable:true, harvestItem:'potato', harvestQty:2 }
|
||
};
|
||
|
||
const ITEMS = {
|
||
meat: { n:'Сырое мясо', icon:'🥩', food:15 },
|
||
cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
|
||
arrow: { n:'Стрела', icon:'➡️', stack:64 },
|
||
chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 },
|
||
feather: { n:'Перо', icon:'🪶', stack:64 },
|
||
bone: { n:'Кость', icon:'🦴', stack:64 },
|
||
gunpowder: { n:'Порох', icon:'💥', stack:64 },
|
||
// === FARMING ITEMS ===
|
||
wheat: { n:'Пшеница', icon:'🌾', stack:64 },
|
||
bread: { n:'Хлеб', icon:'🍞', food:30 },
|
||
carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 },
|
||
potato: { n:'Картофель', icon:'🥔', stack:64 },
|
||
baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 },
|
||
mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 },
|
||
// === MOB DROP ITEMS ===
|
||
scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 },
|
||
polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 },
|
||
slime_ball: { n:'Слизь', icon:'🟢', stack:64 },
|
||
eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 },
|
||
// === NEW ARMOR & TOOLS ===
|
||
gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 }
|
||
};
|
||
|
||
// Seed мира для детерминированной генерации
|
||
// Инициализируем случайным seed, но он будет перезаписан сервером в мультиплеере
|
||
let worldSeed = Math.floor(Math.random() * 1000000);
|
||
|
||
// Отслеживание изменений мира (для оптимизированного сохранения)
|
||
let placedBlocks = []; // [{gx, gy, t}] - блоки, установленные игроком
|
||
let removedBlocks = []; // [{gx, gy}] - блоки, удалённые игроком
|
||
|
||
// Серверные изменения — применяются после genColumn чтобы не перезатирались
|
||
const serverOverrides = new Map(); // key "gx,gy" => {op:'set'|'remove', t?:string}
|
||
|
||
// Инструменты
|
||
const TOOLS = {
|
||
wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 }, requiredLevel: 1 },
|
||
stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 }, requiredLevel: 2 },
|
||
iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 }, requiredLevel: 3 },
|
||
wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 }, requiredLevel: 1 },
|
||
stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 }, requiredLevel: 2 },
|
||
iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 }, requiredLevel: 3 },
|
||
bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, planks: 2 }, requiredLevel: 4 },
|
||
diamond_pickaxe: { n:'Алмазная кирка', icon:'⛏️', durability: 500, miningPower: 5, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 },
|
||
diamond_sword: { n:'Алмазный меч', icon:'⚔️', durability: 400, damage: 18, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 },
|
||
hoe: { n:'Мотыга', icon:'🔨', durability: 80, tillTo: 'farmland', craft: { wood: 2, planks: 1 }, requiredLevel: 1 }
|
||
};
|
||
|
||
// Текстуры блоков (простые)
|
||
const tex = {};
|
||
function makeTex(type) {
|
||
const t = BLOCKS[type];
|
||
const c = document.createElement('canvas');
|
||
c.width = 32; c.height = 32;
|
||
const g = c.getContext('2d');
|
||
|
||
if (type === 'tnt') {
|
||
g.fillStyle='#c0392b'; g.fillRect(0,0,32,32);
|
||
g.fillStyle='#fff'; g.fillRect(0,12,32,8);
|
||
g.fillStyle='#000'; g.font='bold 10px sans-serif'; g.fillText('TNT',6,20);
|
||
return c;
|
||
}
|
||
if (type === 'campfire') {
|
||
g.fillStyle='#5d4037'; g.fillRect(4,26,24,6);
|
||
g.fillStyle='#3e2723'; g.fillRect(7,23,18,4);
|
||
return c;
|
||
}
|
||
if (type === 'torch') {
|
||
g.fillStyle='#6d4c41'; g.fillRect(14,10,4,18);
|
||
g.fillStyle='#f39c12'; g.fillRect(12,6,8,8);
|
||
return c;
|
||
}
|
||
if (type === 'glass') {
|
||
g.fillStyle='rgba(200,240,255,0.25)'; g.fillRect(0,0,32,32);
|
||
g.strokeStyle='rgba(255,255,255,0.65)'; g.strokeRect(2,2,28,28);
|
||
g.beginPath(); g.moveTo(5,27); g.lineTo(27,5); g.stroke();
|
||
return c;
|
||
}
|
||
if (type === 'water') {
|
||
g.fillStyle = t.c; g.fillRect(0,0,32,32);
|
||
g.fillStyle = 'rgba(255,255,255,0.08)';
|
||
g.fillRect(0,6,32,2);
|
||
return c;
|
||
}
|
||
if (type === 'bed') {
|
||
// Основание кровати
|
||
g.fillStyle = '#e91e63';
|
||
g.fillRect(0, 0, 32, 32);
|
||
// Подушка
|
||
g.fillStyle = '#f8bbd0';
|
||
g.fillRect(2, 2, 14, 14);
|
||
// Одеяло
|
||
g.fillStyle = '#c2185b';
|
||
g.fillRect(16, 4, 14, 24);
|
||
// Детали одеяла
|
||
g.fillStyle = '#e91e63';
|
||
g.fillRect(18, 6, 10, 20);
|
||
return c;
|
||
}
|
||
if (type === 'flower') {
|
||
g.fillStyle='#2ecc71'; g.fillRect(14,14,4,18);
|
||
g.fillStyle=t.c; g.beginPath(); g.arc(16,12,6,0,6.28); g.fill();
|
||
return c;
|
||
}
|
||
if (type === 'boat') {
|
||
// Корпус лодки
|
||
g.fillStyle = '#8B4513';
|
||
g.fillRect(2, 12, 28, 8);
|
||
// Борта
|
||
g.fillStyle = '#A0522D';
|
||
g.fillRect(0, 10, 32, 12);
|
||
// Внутренность
|
||
g.fillStyle = '#DEB887';
|
||
g.fillRect(4, 14, 24, 4);
|
||
// Дно
|
||
g.fillStyle = '#654321';
|
||
g.fillRect(2, 20, 28, 4);
|
||
return c;
|
||
}
|
||
if (type === 'ladder') {
|
||
// Боковые стойки лестницы
|
||
g.fillStyle = '#8B4513';
|
||
g.fillRect(4, 0, 4, 32);
|
||
g.fillRect(24, 0, 4, 32);
|
||
// Ступени
|
||
g.fillStyle = '#A0522D';
|
||
g.fillRect(4, 4, 24, 3);
|
||
g.fillRect(4, 12, 24, 3);
|
||
g.fillRect(4, 20, 24, 3);
|
||
g.fillRect(4, 28, 24, 3);
|
||
return c;
|
||
}
|
||
// === BIOME BLOCK TEXTURES ===
|
||
if (type === 'snow') {
|
||
g.fillStyle = '#ecf0f1';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#dfe6e9';
|
||
for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3);
|
||
return c;
|
||
}
|
||
if (type === 'ice') {
|
||
g.fillStyle = '#74b9ff';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.strokeStyle = 'rgba(255,255,255,0.4)';
|
||
g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke();
|
||
g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke();
|
||
return c;
|
||
}
|
||
if (type === 'cactus') {
|
||
g.fillStyle = '#27ae60';
|
||
g.fillRect(6, 2, 20, 28);
|
||
g.fillStyle = '#2ecc71';
|
||
g.fillRect(2, 8, 6, 4);
|
||
g.fillRect(24, 14, 6, 4);
|
||
g.fillStyle = '#1e8449';
|
||
g.fillRect(12, 0, 2, 30);
|
||
g.fillRect(18, 0, 2, 30);
|
||
return c;
|
||
}
|
||
if (type === 'mushroom') {
|
||
g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12);
|
||
g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill();
|
||
g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3);
|
||
return c;
|
||
}
|
||
if (type === 'moss') {
|
||
g.fillStyle = '#0a6640';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#1e8449';
|
||
for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3);
|
||
return c;
|
||
}
|
||
if (type === 'swamp_water') {
|
||
g.fillStyle = 'rgba(100,120,40,0.6)';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = 'rgba(80,100,20,0.3)';
|
||
g.fillRect(0, 10, 32, 2);
|
||
g.fillRect(0, 22, 32, 2);
|
||
return c;
|
||
}
|
||
if (type === 'farmland') {
|
||
g.fillStyle = '#8B6914';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#7a5c10';
|
||
for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2);
|
||
g.fillStyle = '#6B4E0A';
|
||
g.fillRect(8, 2, 2, 28);
|
||
g.fillRect(18, 2, 2, 28);
|
||
return c;
|
||
}
|
||
if (type === 'dead_bush') {
|
||
g.fillStyle = '#b2bec3';
|
||
g.fillRect(12, 16, 2, 16);
|
||
g.fillRect(8, 14, 8, 2);
|
||
g.fillRect(16, 12, 8, 2);
|
||
g.fillRect(6, 18, 4, 2);
|
||
return c;
|
||
}
|
||
if (type === 'spruce_leaves') {
|
||
g.fillStyle = '#0a6640';
|
||
g.fillRect(0, 0, 32, 32);
|
||
g.fillStyle = '#0d7a4d';
|
||
for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4);
|
||
return c;
|
||
}
|
||
// CROP STAGES
|
||
if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) {
|
||
const st = parseInt(type.charAt(type.length-1));
|
||
const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] :
|
||
type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] :
|
||
['#a8e6a0','#c8b888','#bfaa78','#dfe6e9'];
|
||
g.fillStyle = '#5d4037';
|
||
g.fillRect(15, 18, 2, 14);
|
||
if (st >= 1) {
|
||
g.fillStyle = colors[st];
|
||
g.fillRect(10, 8 + (3-st)*3, 12, 10);
|
||
} else {
|
||
g.fillStyle = '#a8e6a0';
|
||
g.fillRect(14, 22, 4, 4);
|
||
}
|
||
return c;
|
||
}
|
||
|
||
g.fillStyle = t.c || '#000';
|
||
g.fillRect(0,0,32,32);
|
||
|
||
g.fillStyle = 'rgba(0,0,0,0.10)';
|
||
for (let i=0;i<6;i++) g.fillRect((Math.random()*28)|0, (Math.random()*28)|0, 4,4);
|
||
|
||
if (type.endsWith('_ore') || type==='coal') {
|
||
g.fillStyle = 'rgba(0,0,0,0.35)';
|
||
for (let i=0;i<4;i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6,6);
|
||
}
|
||
return c;
|
||
}
|
||
Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
|
||
|
||
// Мир-хранилище
|
||
const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...}
|
||
const blocks = []; // для рендера/перебора видимых
|
||
function k(gx,gy){ return gx+','+gy; }
|
||
function getBlock(gx,gy){ return grid.get(k(gx,gy)); }
|
||
function hasBlock(gx,gy){ return grid.has(k(gx,gy)); }
|
||
function isSolid(gx,gy){
|
||
const b = getBlock(gx,gy);
|
||
if(!b || b.dead) return false;
|
||
const def = BLOCKS[b.t];
|
||
return !!def.solid && !def.fluid && !def.decor;
|
||
}
|
||
function setBlock(gx,gy,t, isPlayerPlaced = false){
|
||
const key = k(gx,gy);
|
||
if(grid.has(key)) return false;
|
||
const b = { gx, gy, t, dead:false, active:false, fuse:0 };
|
||
grid.set(key, b);
|
||
blocks.push(b);
|
||
|
||
// Отслеживаем блоки, установленные игроком
|
||
if(isPlayerPlaced){
|
||
placedBlocks.push({gx, gy, t});
|
||
}
|
||
|
||
return true;
|
||
}
|
||
function removeBlock(gx,gy){
|
||
const key = k(gx,gy);
|
||
const b = grid.get(key);
|
||
if(!b) return null;
|
||
if(BLOCKS[b.t].unbreakable) return null;
|
||
grid.delete(key);
|
||
b.dead = true;
|
||
|
||
// Отслеживаем удалённые блоки
|
||
const wasPlayerPlaced = placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
|
||
if(wasPlayerPlaced){
|
||
// Удаляем из placedBlocks
|
||
placedBlocks = placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
|
||
} else {
|
||
// Это природный блок - добавляем в removedBlocks
|
||
removedBlocks.push({gx, gy});
|
||
}
|
||
|
||
return b;
|
||
}
|
||
|
||
// Физика жидкости
|
||
const waterUpdateQueue = new Set();
|
||
let waterUpdateTimer = 0;
|
||
const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды
|
||
|
||
function updateWaterPhysics(dt){
|
||
waterUpdateTimer += dt;
|
||
if(waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
|
||
waterUpdateTimer = 0;
|
||
|
||
// Ограничиваем количество водных блоков для обработки (оптимизация)
|
||
const MAX_WATER_BLOCKS_PER_UPDATE = 50;
|
||
let processedCount = 0;
|
||
|
||
// Собираем только видимые водные блоки в очередь (оптимизация)
|
||
waterUpdateQueue.clear();
|
||
const minGX = Math.floor(camX/TILE) - 10;
|
||
const maxGX = Math.floor((camX+W)/TILE) + 10;
|
||
const minGY = Math.floor(camY/TILE) - 10;
|
||
const maxGY = Math.floor((camY+H)/TILE) + 10;
|
||
|
||
for(const b of blocks){
|
||
if(processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
|
||
if(!b.dead && b.t === 'water' &&
|
||
b.gx >= minGX && b.gx <= maxGX &&
|
||
b.gy >= minGY && b.gy <= maxGY){
|
||
waterUpdateQueue.add(k(b.gx, b.gy));
|
||
processedCount++;
|
||
}
|
||
}
|
||
|
||
// Обновляем воду с ограничением глубины распространения
|
||
const processed = new Set();
|
||
const toAdd = [];
|
||
const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды
|
||
|
||
for(const key of waterUpdateQueue){
|
||
if(processed.has(key)) continue;
|
||
const b = grid.get(key);
|
||
if(!b || b.dead) continue;
|
||
processed.add(key);
|
||
|
||
const gx = b.gx;
|
||
const gy = b.gy;
|
||
|
||
// Проверяем глубину - не распространяем воду слишком глубоко
|
||
if(gy > SEA_GY + MAX_WATER_DEPTH) continue;
|
||
|
||
// Проверяем, можно ли воде упасть вниз
|
||
const belowKey = k(gx, gy + 1);
|
||
const below = grid.get(belowKey);
|
||
|
||
// Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху)
|
||
if(!below || below.dead){
|
||
// Ограничиваем создание новых водных блоков
|
||
if(toAdd.length < 20){ // Максимум 20 новых блоков за обновление
|
||
toAdd.push({gx, gy: gy + 1, t: 'water'});
|
||
processed.add(belowKey);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Если внизу не вода и не твёрдый блок - вода может течь вниз
|
||
if(!isSolid(gx, gy + 1) && below && below.t !== 'water'){
|
||
if(toAdd.length < 20){
|
||
toAdd.push({gx, gy: gy + 1, t: 'water'});
|
||
processed.add(belowKey);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Если внизу твёрдый блок или вода - вода растекается горизонтально
|
||
// Проверяем левую сторону
|
||
const leftKey = k(gx - 1, gy);
|
||
const left = grid.get(leftKey);
|
||
if(!left || left.dead){
|
||
if(toAdd.length < 20){
|
||
toAdd.push({gx: gx - 1, gy, t: 'water'});
|
||
processed.add(leftKey);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Проверяем правую сторону
|
||
const rightKey = k(gx + 1, gy);
|
||
const right = grid.get(rightKey);
|
||
if(!right || right.dead){
|
||
if(toAdd.length < 20){
|
||
toAdd.push({gx: gx + 1, gy, t: 'water'});
|
||
processed.add(rightKey);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Применяем изменения (только добавляем новые блоки)
|
||
for(const newData of toAdd){
|
||
const key = k(newData.gx, newData.gy);
|
||
if(!grid.has(key)){
|
||
const b = {
|
||
gx: newData.gx,
|
||
gy: newData.gy,
|
||
t: newData.t,
|
||
dead: false,
|
||
active: false,
|
||
fuse: 0
|
||
};
|
||
grid.set(key, b);
|
||
blocks.push(b);
|
||
}
|
||
}
|
||
|
||
// Очищаем мёртвые блоки из массива
|
||
for(let i = blocks.length - 1; i >= 0; i--){
|
||
if(blocks[i].dead){
|
||
blocks.splice(i, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Инвентарь
|
||
const inv = {
|
||
dirt:6, stone:0, sand:0, gravel:0, clay:0,
|
||
wood:0, planks:0, ladder:0, leaves:0, coal:0,
|
||
copper_ore:0, iron_ore:0, gold_ore:0, diamond_ore:0,
|
||
brick:0, glass:0,
|
||
tnt:1, campfire:0, torch:0,
|
||
meat:0, cooked:0, arrow:0,
|
||
wood_pickaxe:0, stone_pickaxe:0, iron_pickaxe:0,
|
||
wood_sword:0, stone_sword:0, iron_sword:0,
|
||
iron_armor:0, gold_armor:0,
|
||
bow:0, furnace:0,
|
||
bed:0, boat:0,
|
||
iron_ingot:0, gold_ingot:0, copper_ingot:0,
|
||
diamond_pickaxe:0, diamond_sword:0, hoe:0,
|
||
wheat:0, bread:0, carrot:0, potato:0, baked_potato:0,
|
||
scorpion_stinger:0, polar_fur:0, slime_ball:0, eagle_feather:0,
|
||
snow:0, ice:0, cactus:0, mushroom:0, moss:0, farmland:0,
|
||
spruce_leaves:0, dead_bush:0,
|
||
wheat_stage0:0, carrot_stage0:0, potato_stage0:0
|
||
};
|
||
let selected = 'dirt';
|
||
|
||
// Прочность инструментов: Map<"tooltype_id", {current, max}>
|
||
// При крафте инструмента создаём запись с max durability
|
||
const toolDurability = new Map();
|
||
|
||
function addTool(type) {
|
||
const def = TOOLS[type];
|
||
if (!def) return;
|
||
const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
|
||
toolDurability.set(id, { type, current: def.durability, max: def.durability });
|
||
return id;
|
||
}
|
||
|
||
function getToolDurability(id) {
|
||
return toolDurability.get(id);
|
||
}
|
||
|
||
// Найти лучший инструмент данного типа в инвентаре
|
||
function findBestTool(toolType) {
|
||
if (inv[toolType] <= 0) return null;
|
||
// Возвращаем первый попавшийся — упрощённо
|
||
return toolType;
|
||
}
|
||
|
||
// Использовать инструмент (уменьшить прочность). Возвращает true если сломался
|
||
function useTool(toolType) {
|
||
// Ищем любой инструмент этого типа с прочностью
|
||
for (const [id, dur] of toolDurability) {
|
||
if (dur.type === toolType) {
|
||
dur.current--;
|
||
if (dur.current <= 0) {
|
||
toolDurability.delete(id);
|
||
inv[toolType]--;
|
||
rebuildHotbar();
|
||
return true; // сломался
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const RECIPES = [
|
||
{ out:'planks', qty:4, cost:{ wood:1 }, requiredLevel:1 },
|
||
{ out:'ladder', qty:3, cost:{ planks:7 }, requiredLevel:1 },
|
||
{ out:'torch', qty:2, cost:{ coal:1, planks:1 }, requiredLevel:1 },
|
||
{ out:'glass', qty:1, cost:{ sand:3 }, requiredLevel:1 },
|
||
{ out:'brick', qty:1, cost:{ stone:2, clay:1 }, requiredLevel:1 },
|
||
{ out:'campfire', qty:1, cost:{ wood:1, coal:1 }, requiredLevel:1 },
|
||
{ out:'tnt', qty:1, cost:{ sand:2, coal:1 }, requiredLevel:8 },
|
||
{ out:'bed', qty:1, cost:{ wood: 3, planks: 3 }, requiredLevel:1 },
|
||
{ out:'boat', qty:1, cost:{ wood: 5 }, requiredLevel:2 },
|
||
{ out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:1 },
|
||
{ out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 }, requiredLevel:2 },
|
||
{ out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 }, requiredLevel:3 },
|
||
{ out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 },
|
||
{ out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 }, requiredLevel:2 },
|
||
{ out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 }, requiredLevel:3 },
|
||
{ out:'iron_armor', qty:1, cost:{ iron_ore: 5 }, requiredLevel:5 },
|
||
{ out:'gold_armor', qty:1, cost:{ gold_ore: 8 }, requiredLevel:6 },
|
||
{ out:'furnace', qty:1, cost:{ stone: 8 }, requiredLevel:3 },
|
||
{ out:'bow', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:4 },
|
||
{ out:'arrow', qty:4, cost:{ stone: 1, wood: 1 }, requiredLevel:1 },
|
||
// === NEW RECIPES ===
|
||
{ out:'bread', qty:1, cost:{ wheat: 3 }, requiredLevel:1 },
|
||
{ out:'diamond_pickaxe', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 },
|
||
{ out:'diamond_sword', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 },
|
||
{ out:'hoe', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 }
|
||
];
|
||
|
||
// Рецепты печи (обжиг)
|
||
const SMELTING_RECIPES = [
|
||
{ in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло
|
||
{ in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич
|
||
{ in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток
|
||
{ in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток
|
||
{ in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток
|
||
{ in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное
|
||
{ in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 }, // булыжник → камень
|
||
{ in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 } // картофель → печёная картошка
|
||
];
|
||
|
||
// Новые предметы от обжига
|
||
ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
|
||
ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
|
||
ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
|
||
ITEMS.diamond_pickaxe = { n:'Алмазная кирка', icon:'⛏️', durability:500, miningPower:5 };
|
||
ITEMS.diamond_sword = { n:'Алмазный меч', icon:'⚔️', durability:400, damage:18 };
|
||
ITEMS.hoe = { n:'Мотыга', icon:'🔨', durability:80, tillTo:'farmland' };
|
||
|
||
// Активные печи: Map ключа блока → { recipe, progress, totalTime }
|
||
const activeFurnaces = new Map();
|
||
|
||
// UI
|
||
const hpEl = document.getElementById('hp');
|
||
const foodEl = document.getElementById('food');
|
||
const sxEl = document.getElementById('sx');
|
||
const syEl = document.getElementById('sy');
|
||
const todEl = document.getElementById('tod');
|
||
const worldIdEl = document.getElementById('worldId');
|
||
const playerCountEl = document.getElementById('playerCount');
|
||
const hotbarEl = document.getElementById('hotbar');
|
||
const craftPanel = document.getElementById('craftPanel');
|
||
const recipesEl = document.getElementById('recipes');
|
||
const deathEl = document.getElementById('death');
|
||
const inventoryPanel = document.getElementById('inventoryPanel');
|
||
const inventoryGrid = document.getElementById('inventoryGrid');
|
||
|
||
// ==================== МИНИКАРТА ====================
|
||
const minimapWrap = document.getElementById('minimapWrap');
|
||
const minimapCanvas = document.getElementById('minimap');
|
||
const minimapCtx = minimapCanvas.getContext('2d');
|
||
let minimapOpen = false;
|
||
|
||
document.getElementById('mapToggle').onclick = () => {
|
||
playSound('click');
|
||
minimapOpen = !minimapOpen;
|
||
minimapWrap.style.display = minimapOpen ? 'block' : 'none';
|
||
};
|
||
|
||
// Цвета блоков для миникарты (по 1 пикселю на блок)
|
||
const MINIMAP_COLORS = {
|
||
grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
|
||
gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
|
||
leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
|
||
copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
|
||
diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
|
||
campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
|
||
flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410',
|
||
snow: '#ecf0f1', ice: '#74b9ff', cactus: '#27ae60', mushroom: '#e74c3c',
|
||
moss: '#0a6640', swamp_water: '#687828', farmland: '#8B6914',
|
||
dead_bush: '#b2bec3', spruce_leaves: '#0a6640',
|
||
wheat_stage0: '#a8e6a0', wheat_stage1: '#7dcea0', wheat_stage2: '#b8d730', wheat_stage3: '#f1c40f',
|
||
carrot_stage0: '#a8e6a0', carrot_stage1: '#f0c27a', carrot_stage2: '#e8a040', carrot_stage3: '#e67e22',
|
||
potato_stage0: '#a8e6a0', potato_stage1: '#c8b888', potato_stage2: '#bfaa78', potato_stage3: '#dfe6e9'
|
||
};
|
||
|
||
function renderMinimap() {
|
||
if (!minimapOpen) return;
|
||
const mW = minimapCanvas.width;
|
||
const mH = minimapCanvas.height;
|
||
const scale = 2; // пикселей на блок
|
||
|
||
// Область карты — центрирована на игроке
|
||
const pGX = Math.floor(player.x / TILE);
|
||
const pGY = Math.floor(player.y / TILE);
|
||
const viewW = Math.floor(mW / scale);
|
||
const viewH = Math.floor(mH / scale);
|
||
const startGX = pGX - Math.floor(viewW / 2);
|
||
const startGY = pGY - Math.floor(viewH / 2);
|
||
|
||
// Очищаем
|
||
minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB';
|
||
minimapCtx.fillRect(0, 0, mW, mH);
|
||
|
||
// Рисуем блоки
|
||
const imgData = minimapCtx.createImageData(mW, mH);
|
||
const data = imgData.data;
|
||
|
||
for (let dx = 0; dx < viewW; dx++) {
|
||
for (let dy = 0; dy < viewH; dy++) {
|
||
const gx = startGX + dx;
|
||
const gy = startGY + dy;
|
||
const b = getBlock(gx, gy);
|
||
if (!b || b.dead || b.t === 'air') continue;
|
||
|
||
const color = MINIMAP_COLORS[b.t];
|
||
if (!color) continue;
|
||
|
||
// Парсим hex цвет
|
||
const r = parseInt(color.slice(1,3), 16);
|
||
const g = parseInt(color.slice(3,5), 16);
|
||
const bl = parseInt(color.slice(5,7), 16);
|
||
|
||
// Заполняем scale x scale пикселей
|
||
for (let sx = 0; sx < scale; sx++) {
|
||
for (let sy = 0; sy < scale; sy++) {
|
||
const px = dx * scale + sx;
|
||
const py = dy * scale + sy;
|
||
if (px >= mW || py >= mH) continue;
|
||
const idx = (py * mW + px) * 4;
|
||
data[idx] = r;
|
||
data[idx+1] = g;
|
||
data[idx+2] = bl;
|
||
data[idx+3] = 255;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
minimapCtx.putImageData(imgData, 0, 0);
|
||
|
||
// Игрок — белый пиксель по центру
|
||
minimapCtx.fillStyle = '#fff';
|
||
minimapCtx.fillRect(Math.floor(mW/2) - 2, Math.floor(mH/2) - 2, 4, 4);
|
||
|
||
// Другие игроки — жёлтые точки
|
||
for (const [sid, p] of otherPlayers) {
|
||
const dx = Math.floor(p.x / TILE) - startGX;
|
||
const dy = Math.floor(p.y / TILE) - startGY;
|
||
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
|
||
minimapCtx.fillStyle = '#f1c40f';
|
||
minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3);
|
||
}
|
||
}
|
||
|
||
// Мобы — красные (враждебные) / зелёные (животные)
|
||
const allMobsForMap = getAllMobs();
|
||
for (const m of allMobsForMap) {
|
||
const dx = Math.floor(m.x / TILE) - startGX;
|
||
const dy = Math.floor(m.y / TILE) - startGY;
|
||
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
|
||
const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton';
|
||
minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71';
|
||
minimapCtx.fillRect(dx * scale, dy * scale, 2, 2);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== ПЕЧЬ (ОБЖИГ) ====================
|
||
const furnacePanel = document.getElementById('furnacePanel');
|
||
const furnaceContent = document.getElementById('furnaceContent');
|
||
let currentFurnaceKey = null; // "gx,gy" текущей открытой печи
|
||
|
||
document.getElementById('furnaceClose').onclick = () => {
|
||
furnacePanel.style.display = 'none';
|
||
currentFurnaceKey = null;
|
||
};
|
||
|
||
function openFurnaceUI(gx, gy) {
|
||
currentFurnaceKey = `${gx},${gy}`;
|
||
furnacePanel.style.display = 'block';
|
||
renderFurnaceUI();
|
||
}
|
||
|
||
function renderFurnaceUI() {
|
||
if (!currentFurnaceKey) return;
|
||
|
||
// Проверяем что печь всё ещё существует
|
||
const [fgx, fgy] = currentFurnaceKey.split(',').map(Number);
|
||
const fb = getBlock(fgx, fgy);
|
||
if (!fb || fb.t !== 'furnace') {
|
||
furnacePanel.style.display = 'none';
|
||
currentFurnaceKey = null;
|
||
return;
|
||
}
|
||
|
||
// Текущий процесс обжига
|
||
const active = activeFurnaces.get(currentFurnaceKey);
|
||
|
||
let html = '<div style="color:#fff;font-size:13px;">';
|
||
|
||
// Доступные рецепты — показываем только те, для которых есть ресурсы
|
||
for (let i = 0; i < SMELTING_RECIPES.length; i++) {
|
||
const recipe = SMELTING_RECIPES[i];
|
||
const haveCount = inv[recipe.in] || 0;
|
||
const canSmelt = haveCount >= recipe.qty;
|
||
|
||
// Иконка результата
|
||
const outDef = BLOCKS[recipe.out];
|
||
const outItem = ITEMS[recipe.out];
|
||
const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓');
|
||
const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out);
|
||
const inItem = ITEMS[recipe.in];
|
||
const inDef = BLOCKS[recipe.in];
|
||
const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in);
|
||
|
||
html += `<div class="recipe">`;
|
||
html += `<div class="ricon" style="font-size:24px;display:flex;align-items:center;justify-content:center;">${iconStr}</div>`;
|
||
html += `<div class="rinfo">`;
|
||
html += `<div class="rname">${nameStr}</div>`;
|
||
html += `<div class="rcost">${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с</div>`;
|
||
html += `</div>`;
|
||
html += `<button class="rcraft" onclick="window._smelt(${i})" ${canSmelt ? '' : 'disabled'}>🔥</button>`;
|
||
html += `</div>`;
|
||
}
|
||
|
||
// Текущий прогресс
|
||
if (active) {
|
||
const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
|
||
html += `<div style="margin-top:10px;padding:8px;background:rgba(255,255,255,0.1);border-radius:8px;">`;
|
||
html += `<div style="color:#f39c12;font-weight:900;">🔥 Обжиг: ${pct}%</div>`;
|
||
html += `<div style="background:#333;height:8px;border-radius:4px;margin-top:4px;">`;
|
||
html += `<div style="background:#f39c12;height:8px;border-radius:4px;width:${pct}%;"></div>`;
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
furnaceContent.innerHTML = html;
|
||
}
|
||
|
||
// Глобальная функция для кнопки обжига
|
||
window._smelt = (recipeIdx) => {
|
||
if (!currentFurnaceKey) return;
|
||
const recipe = SMELTING_RECIPES[recipeIdx];
|
||
if ((inv[recipe.in] || 0) < recipe.qty) return;
|
||
|
||
// Уже обжигаем в этой печи?
|
||
if (activeFurnaces.has(currentFurnaceKey)) return;
|
||
|
||
// Забираем ресурсы
|
||
inv[recipe.in] -= recipe.qty;
|
||
|
||
// Запускаем обжиг
|
||
activeFurnaces.set(currentFurnaceKey, {
|
||
recipe: recipe,
|
||
progress: 0
|
||
});
|
||
|
||
playSound('fire');
|
||
rebuildHotbar();
|
||
renderFurnaceUI();
|
||
};
|
||
|
||
// Тик печей — вызывается в главном цикле
|
||
function tickFurnaces(dt) {
|
||
for (const [key, furnace] of activeFurnaces) {
|
||
furnace.progress += dt;
|
||
if (furnace.progress >= furnace.recipe.time) {
|
||
// Обжиг завершён — выдаём результат
|
||
const outItem = furnace.recipe.out;
|
||
if (ITEMS[outItem]) {
|
||
inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty;
|
||
} else if (BLOCKS[outItem]) {
|
||
inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty;
|
||
}
|
||
playSound('stone_build');
|
||
activeFurnaces.delete(key);
|
||
|
||
// Если эта печь открыта — обновляем UI
|
||
if (key === currentFurnaceKey) {
|
||
renderFurnaceUI();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== ГОЛОСОВОЙ ЧАТ ====================
|
||
let voiceSocket = null;
|
||
let voiceStream = null;
|
||
let audioCtx = null;
|
||
let voiceProcessor = null;
|
||
let voiceActive = false;
|
||
let voiceMode = 'near'; // 'near' or 'world'
|
||
let voiceDebugCount = 0;
|
||
const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
|
||
|
||
// Кнопка микрофона
|
||
const voiceBtn = document.createElement('div');
|
||
voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
|
||
voiceBtn.title = 'Голосовой чат (выкл)';
|
||
voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
|
||
document.querySelector('.ui').appendChild(voiceBtn);
|
||
|
||
// Кнопка режима голоса (близко / весь мир)
|
||
const voiceModeBtn = document.createElement('div');
|
||
voiceModeBtn.innerHTML = '📢';
|
||
voiceModeBtn.title = 'Режим: рядом (600px)';
|
||
voiceModeBtn.style.cssText = 'position:absolute;top:74px;right:190px;width:48px;height:52px;border-radius:12px;background:#3498db;z-index:200;display:flex;align-items:center;justify-content:center;font-size:20px;cursor:pointer;border:2px solid rgba(255,255,255,0.7);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;color:#fff;font-weight:bold;';
|
||
document.querySelector('.ui').appendChild(voiceModeBtn);
|
||
voiceModeBtn.onclick = () => {
|
||
if (voiceMode === 'near') {
|
||
voiceMode = 'world';
|
||
voiceModeBtn.innerHTML = '🌍';
|
||
voiceModeBtn.title = 'Режим: весь мир';
|
||
voiceModeBtn.style.background = '#e67e22';
|
||
} else {
|
||
voiceMode = 'near';
|
||
voiceModeBtn.innerHTML = '📢';
|
||
voiceModeBtn.title = 'Режим: рядом (600px)';
|
||
voiceModeBtn.style.background = '#3498db';
|
||
}
|
||
if (voiceSocket && voiceSocket.connected) {
|
||
voiceSocket.emit('voice_mode', { mode: voiceMode });
|
||
}
|
||
};
|
||
|
||
// Индикатор говорящего
|
||
const speakingIndicator = document.createElement('div');
|
||
speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;';
|
||
speakingIndicator.textContent = '🔊';
|
||
document.querySelector('.ui').appendChild(speakingIndicator);
|
||
let speakingTimeout = null;
|
||
|
||
voiceBtn.onclick = async () => {
|
||
if (voiceActive) {
|
||
// Выключить
|
||
voiceActive = false;
|
||
ringReady = 0; ringRead = ringWrite; voicePlayActive = false;
|
||
voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
|
||
voiceBtn.style.background = '#555';
|
||
if (voiceStream) {
|
||
voiceStream.getTracks().forEach(t => t.stop());
|
||
voiceStream = null;
|
||
}
|
||
if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; }
|
||
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||
if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
|
||
return;
|
||
}
|
||
|
||
// Включить
|
||
try {
|
||
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
|
||
audioCtx = new AudioContext({ sampleRate: 24000 });
|
||
if (audioCtx.state === 'suspended') await audioCtx.resume();
|
||
console.log('[voice] AudioContext state:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate);
|
||
|
||
const source = audioCtx.createMediaStreamSource(voiceStream);
|
||
voiceProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
|
||
console.log('[voice] ScriptProcessor created, bufferSize=2048');
|
||
|
||
voiceProcessor.onaudioprocess = (e) => {
|
||
if (!voiceActive) return;
|
||
voiceDebugCount++;
|
||
if (voiceDebugCount <= 5) {
|
||
const pcm = e.inputBuffer.getChannelData(0);
|
||
console.log('[voice] onaudioprocess #' + voiceDebugCount, 'samples:', pcm.length, 'max:', Math.max(...pcm.map(Math.abs)).toFixed(4), 'socket:', !!voiceSocket, 'connected:', voiceSocket?.connected);
|
||
}
|
||
if (!voiceSocket || !voiceSocket.connected) return;
|
||
const pcm = e.inputBuffer.getChannelData(0);
|
||
const int16 = new Int16Array(pcm.length);
|
||
for (let i = 0; i < pcm.length; i++) {
|
||
const s = Math.max(-1, Math.min(1, pcm[i]));
|
||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||
}
|
||
voiceSocket.emit('voice_data', int16.buffer);
|
||
};
|
||
|
||
// Chain: source → processor → gain(0) → destination
|
||
// ScriptProcessor MUST reach destination to fire onaudioprocess
|
||
const silentGain = audioCtx.createGain();
|
||
silentGain.gain.value = 0;
|
||
source.connect(voiceProcessor);
|
||
voiceProcessor.connect(silentGain);
|
||
silentGain.connect(audioCtx.destination);
|
||
console.log('[voice] Audio chain: source → processor → silentGain(0) → destination');
|
||
|
||
// Подключаемся к голосовому серверу
|
||
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
|
||
voiceSocket.on('connect', () => {
|
||
console.log('[voice] Socket connected, id:', voiceSocket.id);
|
||
voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок', mode: voiceMode });
|
||
});
|
||
voiceSocket.on('connect_error', (err) => {
|
||
console.error('[voice] Socket connect error:', err.message);
|
||
});
|
||
|
||
// === Ring Buffer + ScriptProcessor приём голоса ===
|
||
// Единый непрерывный поток вместо отдельных BufferSource на чанк
|
||
const RING_SIZE = 24000 * 3; // 3 секунды ring buffer
|
||
const ringBuf = new Float32Array(RING_SIZE);
|
||
let ringWrite = 0; // позиция записи
|
||
let ringRead = 0; // позиция чтения
|
||
let ringReady = 0; // сколько сэмплов готово
|
||
let voicePlayActive = false;
|
||
const JBUF_TARGET = 4800; // целевой jitter buffer: 200мс при 24kHz
|
||
let jbufFill = 0; // текущее заполнение
|
||
let lastVoiceFrom = ''; // кто говорит (для индикатора)
|
||
|
||
// Воспроизводящий ScriptProcessor — читает из ring buffer
|
||
const playProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
|
||
let lastSample = 0; // для плавного fade при underrun
|
||
playProcessor.onaudioprocess = (e) => {
|
||
const out = e.outputBuffer.getChannelData(0);
|
||
if (ringReady < 1) {
|
||
// Плавный fade-out от последнего сэмпла к тишине
|
||
for (let i = 0; i < out.length; i++) {
|
||
lastSample *= 0.9;
|
||
out[i] = lastSample;
|
||
}
|
||
voicePlayActive = false;
|
||
return;
|
||
}
|
||
// Ждём накопления jitter buffer перед стартом
|
||
if (!voicePlayActive && ringReady >= JBUF_TARGET) {
|
||
voicePlayActive = true;
|
||
}
|
||
if (!voicePlayActive) {
|
||
// Плавно затихаем пока буфер копится
|
||
for (let i = 0; i < out.length; i++) {
|
||
lastSample *= 0.95;
|
||
out[i] = lastSample;
|
||
}
|
||
return;
|
||
}
|
||
// Читаем из ring buffer с плавным fade-in на старте
|
||
for (let i = 0; i < out.length; i++) {
|
||
if (ringReady > 0) {
|
||
out[i] = ringBuf[ringRead];
|
||
lastSample = out[i]; // запоминаем для fade-out
|
||
ringRead = (ringRead + 1) % RING_SIZE;
|
||
ringReady--;
|
||
} else {
|
||
// Underrun — плавно затихаем
|
||
lastSample *= 0.85;
|
||
out[i] = lastSample;
|
||
}
|
||
}
|
||
};
|
||
const playGain = audioCtx.createGain();
|
||
playGain.gain.value = 1.0;
|
||
playProcessor.connect(playGain).connect(audioCtx.destination);
|
||
|
||
voiceSocket.on('voice_in', (payload) => {
|
||
// Пишем входящий голос в ring buffer
|
||
const { data, meta, volume } = payload;
|
||
if (!audioCtx || audioCtx.state === 'closed') return;
|
||
|
||
const int16 = new Int16Array(data);
|
||
const vol = Math.min(1.4, (volume || 1) * 1.5);
|
||
for (let i = 0; i < int16.length; i++) {
|
||
const sample = (int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF)) * vol;
|
||
ringBuf[ringWrite] = sample;
|
||
ringWrite = (ringWrite + 1) % RING_SIZE;
|
||
ringReady = Math.min(ringReady + 1, RING_SIZE);
|
||
}
|
||
// Сброс jitter fill если пауза была
|
||
jbufFill = ringReady;
|
||
lastVoiceFrom = meta.name || '???';
|
||
|
||
// Индикатор
|
||
speakingIndicator.style.display = 'block';
|
||
speakingIndicator.textContent = '🔊 ' + lastVoiceFrom;
|
||
clearTimeout(speakingTimeout);
|
||
speakingTimeout = setTimeout(() => {
|
||
speakingIndicator.style.display = 'none';
|
||
voicePlayActive = false; // сброс при паузе
|
||
ringReady = 0; // очистить буфер
|
||
ringRead = ringWrite; // синхронизировать
|
||
}, 1500);
|
||
});
|
||
|
||
voiceActive = true;
|
||
voiceBtn.textContent = '🎤';
|
||
voiceBtn.style.background = '#2ecc71';
|
||
console.log('[voice] Voice chat ACTIVE');
|
||
} catch(e) {
|
||
console.error('[voice] Error:', e);
|
||
voiceBtn.style.background = '#e74c3c';
|
||
}
|
||
};
|
||
|
||
// Обновляем позицию для voice server
|
||
const origPlayerMove = () => {};
|
||
// Хук в главный цикл — обновляем позицию каждые ~500ms
|
||
let voicePosT = 0;
|
||
|
||
// Клик на часы для включения ночи
|
||
todEl.style.cursor = 'pointer';
|
||
todEl.onclick = () => {
|
||
playSound('click');
|
||
worldTime = 0.6; // Устанавливаем ночь
|
||
isNightTime = true;
|
||
};
|
||
|
||
function rebuildHotbar(){
|
||
hotbarEl.innerHTML='';
|
||
|
||
// Показываем последние 5 выбранных предметов (если они есть в инвентаре)
|
||
const items = recentItems.filter(id => inv[id] > 0).slice(0, 5);
|
||
|
||
for(const id of items){
|
||
const s = document.createElement('div');
|
||
s.className = 'slot'+(id===selected?' sel':'');
|
||
if(BLOCKS[id]) {
|
||
s.style.backgroundImage = `url(${tex[id].toDataURL()})`;
|
||
s.style.backgroundSize = 'cover';
|
||
} else if(ITEMS[id]) {
|
||
s.textContent = ITEMS[id].icon;
|
||
} else if(TOOLS[id]) {
|
||
s.textContent = TOOLS[id].icon;
|
||
} else if(id === 'iron_armor') {
|
||
s.textContent = '🛡️';
|
||
s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
|
||
}
|
||
const c = document.createElement('div');
|
||
c.className='count';
|
||
c.textContent = inv[id];
|
||
s.appendChild(c);
|
||
s.onclick = () => {
|
||
playSound('click'); // Звук клика по инвентарю
|
||
if(selected === id) {
|
||
// Повторный клик — снимаем выбор, возвращаем к первому блоку
|
||
selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
|
||
} else {
|
||
selected = id;
|
||
// Обновляем список последних предметов
|
||
recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
|
||
recentItems.unshift(id); // Добавляем в начало
|
||
recentItems = recentItems.slice(0, 5); // Оставляем только 5
|
||
}
|
||
rebuildHotbar();
|
||
};
|
||
|
||
// Показываем индикатор надетой брони
|
||
if(id === 'iron_armor' && player.equippedArmor === 'iron_armor') {
|
||
const equipped = document.createElement('div');
|
||
equipped.className = 'equipped-indicator';
|
||
equipped.textContent = '✓';
|
||
s.appendChild(equipped);
|
||
}
|
||
|
||
// Durability bar для инструментов
|
||
if(TOOLS[id] && inv[id] > 0) {
|
||
// Находим текущую прочность
|
||
let curDur = 0, maxDur = TOOLS[id].durability;
|
||
for (const [tid, dur] of toolDurability) {
|
||
if (dur.type === id) {
|
||
curDur = dur.current;
|
||
maxDur = dur.max;
|
||
break;
|
||
}
|
||
}
|
||
if (maxDur > 0) {
|
||
const bar = document.createElement('div');
|
||
bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
|
||
const fill = document.createElement('div');
|
||
const pct = curDur / maxDur;
|
||
const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c';
|
||
fill.style.cssText = `width:${pct*100}%;height:100%;background:${color};`;
|
||
bar.appendChild(fill);
|
||
s.appendChild(bar);
|
||
}
|
||
}
|
||
hotbarEl.appendChild(s);
|
||
}
|
||
}
|
||
|
||
function renderInventory() {
|
||
inventoryGrid.innerHTML = '';
|
||
|
||
// Создаём сетку инвентаря 7x3
|
||
const items = Object.keys(inv).filter(id => inv[id] > 0);
|
||
|
||
// Добавляем пустые слоты для полной сетки
|
||
for(let i = 0; i < 21; i++) {
|
||
const slot = document.createElement('div');
|
||
slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
|
||
|
||
if(i < items.length) {
|
||
const id = items[i];
|
||
if(BLOCKS[id]) {
|
||
slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
|
||
slot.style.backgroundSize = 'cover';
|
||
} else if(ITEMS[id]) {
|
||
slot.textContent = ITEMS[id].icon;
|
||
} else if(TOOLS[id]) {
|
||
slot.textContent = TOOLS[id].icon;
|
||
} else if(id === 'iron_armor') {
|
||
slot.textContent = '🛡️';
|
||
slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
|
||
}
|
||
|
||
const count = document.createElement('div');
|
||
count.className = 'inv-count';
|
||
count.textContent = inv[id];
|
||
slot.appendChild(count);
|
||
|
||
slot.onclick = () => {
|
||
playSound('click'); // Звук клика по инвентарю
|
||
if(selected === id) {
|
||
selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
|
||
} else {
|
||
selected = id;
|
||
recentItems = recentItems.filter(item => item !== id);
|
||
recentItems.unshift(id);
|
||
recentItems = recentItems.slice(0, 5);
|
||
}
|
||
rebuildHotbar();
|
||
renderInventory();
|
||
};
|
||
|
||
// Двойной клик для надевания брони
|
||
slot.ondblclick = () => {
|
||
if(id === 'iron_armor' && inv.iron_armor > 0) {
|
||
// Если уже надета броня - снимаем её
|
||
if(player.equippedArmor === 'iron_armor') {
|
||
player.equippedArmor = null;
|
||
player.armor = 0;
|
||
console.log('[ARMOR] Iron armor unequipped');
|
||
} else {
|
||
// Надеваем броню
|
||
player.equippedArmor = 'iron_armor';
|
||
player.armor = BLOCKS['iron_armor'].armor;
|
||
console.log('[ARMOR] Iron armor equipped - armor:', player.armor);
|
||
}
|
||
playSound('click');
|
||
renderInventory();
|
||
}
|
||
if(id === 'gold_armor' && inv.gold_armor > 0) {
|
||
if(player.equippedArmor === 'gold_armor') {
|
||
player.equippedArmor = null;
|
||
player.armor = 0;
|
||
} else {
|
||
player.equippedArmor = 'gold_armor';
|
||
player.armor = ITEMS['gold_armor'].armor;
|
||
}
|
||
playSound('click');
|
||
renderInventory();
|
||
}
|
||
};
|
||
}
|
||
|
||
inventoryGrid.appendChild(slot);
|
||
}
|
||
}
|
||
|
||
function canCraft(r){
|
||
console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost);
|
||
for(const res in r.cost){
|
||
const have = inv[res] || 0;
|
||
const need = r.cost[res];
|
||
console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need);
|
||
if(have < need) return false;
|
||
}
|
||
return true;
|
||
}
|
||
function renderCraft(){
|
||
recipesEl.innerHTML='';
|
||
for(const r of RECIPES){
|
||
const row = document.createElement('div');
|
||
row.className='recipe';
|
||
const reqLv = r.requiredLevel || 1;
|
||
const locked = player.level < reqLv;
|
||
const icon = document.createElement('div');
|
||
icon.className='ricon';
|
||
// Иконка — блок, инструмент или предмет
|
||
if(tex[r.out]){
|
||
icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
|
||
} else if(TOOLS[r.out]){
|
||
icon.textContent = TOOLS[r.out].icon;
|
||
icon.style.fontSize = '24px';
|
||
icon.style.display = 'flex';
|
||
icon.style.alignItems = 'center';
|
||
icon.style.justifyContent = 'center';
|
||
} else if(ITEMS[r.out]){
|
||
icon.textContent = ITEMS[r.out].icon;
|
||
icon.style.fontSize = '24px';
|
||
icon.style.display = 'flex';
|
||
icon.style.alignItems = 'center';
|
||
icon.style.justifyContent = 'center';
|
||
}
|
||
const info = document.createElement('div');
|
||
info.className='rinfo';
|
||
const nm = document.createElement('div');
|
||
nm.className='rname';
|
||
const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
|
||
nm.textContent = `${itemName} x${r.qty}` + (locked ? ` (Lv.${reqLv})` : '');
|
||
if(locked) { nm.style.color = '#888'; nm.style.textDecoration = 'line-through'; }
|
||
const cs = document.createElement('div');
|
||
cs.className='rcost';
|
||
cs.textContent = Object.keys(r.cost).map(x => {
|
||
const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
|
||
return `${cn}: ${(inv[x]||0)}/${r.cost[x]}`;
|
||
}).join(' ');
|
||
if(locked) cs.style.color = '#666';
|
||
info.appendChild(nm); info.appendChild(cs);
|
||
const btn = document.createElement('button');
|
||
btn.className='rcraft';
|
||
btn.textContent='Создать';
|
||
btn.disabled = !canCraft(r) || locked;
|
||
if(locked) btn.title = `Требуется уровень ${reqLv}`;
|
||
btn.onclick = () => {
|
||
if(!canCraft(r) || locked) return;
|
||
playSound('click');
|
||
for(const res in r.cost) inv[res]-=r.cost[res];
|
||
inv[r.out] = (inv[r.out]||0) + r.qty;
|
||
if(TOOLS[r.out]) addTool(r.out);
|
||
rebuildHotbar();
|
||
renderCraft();
|
||
};
|
||
row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
|
||
recipesEl.appendChild(row);
|
||
}
|
||
}
|
||
|
||
let craftOpen=false;
|
||
let inventoryOpen = false;
|
||
|
||
document.getElementById('craftBtn').onclick = () => {
|
||
playSound('click'); // Звук клика по кнопке
|
||
craftOpen = !craftOpen;
|
||
craftPanel.style.display = craftOpen ? 'block' : 'none';
|
||
if(craftOpen) {
|
||
renderCraft();
|
||
// Закрываем инвентарь если открыт крафт
|
||
inventoryOpen = false;
|
||
inventoryPanel.style.display = 'none';
|
||
}
|
||
};
|
||
document.getElementById('craftClose').onclick = () => {
|
||
playSound('click'); // Звук клика по кнопке
|
||
craftOpen = false;
|
||
craftPanel.style.display = 'none';
|
||
};
|
||
|
||
// Кнопка открытия инвентаря
|
||
document.getElementById('invToggle').onclick = () => {
|
||
playSound('click'); // Звук клика по кнопке
|
||
inventoryOpen = !inventoryOpen;
|
||
inventoryPanel.style.display = inventoryOpen ? 'block' : 'none';
|
||
if(inventoryOpen) {
|
||
renderInventory();
|
||
// Закрываем крафт если открыт инвентарь
|
||
craftOpen = false;
|
||
craftPanel.style.display = 'none';
|
||
}
|
||
};
|
||
|
||
document.getElementById('inventoryClose').onclick = () => {
|
||
playSound('click'); // Звук клика по кнопке
|
||
inventoryOpen = false;
|
||
inventoryPanel.style.display = 'none';
|
||
};
|
||
|
||
// Кнопка сохранения игры (только для одиночного режима)
|
||
const saveBtn = document.getElementById('saveBtn');
|
||
saveBtn.onclick = () => {
|
||
playSound('click');
|
||
saveGame();
|
||
customAlert('Игра сохранена!');
|
||
};
|
||
|
||
// Кнопка сброса игры (удаление сохранения и создание нового мира)
|
||
const resetBtn = document.getElementById('resetBtn');
|
||
resetBtn.onclick = () => {
|
||
customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => {
|
||
playSound('click');
|
||
|
||
// Удаляем сохранение из localStorage
|
||
try {
|
||
localStorage.removeItem(SAVE_KEY);
|
||
console.log('Сохранение удалено из localStorage');
|
||
} catch (e) {
|
||
console.warn('Ошибка удаления сохранения:', e);
|
||
}
|
||
|
||
// Сбрасываем in-memory сохранение
|
||
inMemorySave = null;
|
||
|
||
// Генерируем новый worldId
|
||
worldId = Math.random().toString(36).substring(2, 10);
|
||
console.log('Новый worldId после сброса:', worldId);
|
||
|
||
// Обновляем URL
|
||
try {
|
||
const newUrl = new URL(window.location.href);
|
||
newUrl.searchParams.set('world', worldId);
|
||
const newUrlString = newUrl.toString();
|
||
|
||
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
|
||
window.history.replaceState(null, '', newUrlString);
|
||
console.log('URL обновлён:', newUrlString);
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка обновления URL:', e);
|
||
}
|
||
|
||
// Перезагружаем страницу
|
||
location.reload();
|
||
});
|
||
};
|
||
|
||
// Показываем кнопку сохранения только если играем одни
|
||
function updateSaveButtonVisibility() {
|
||
if (isMultiplayer && otherPlayers.size > 0) {
|
||
saveBtn.style.display = 'none';
|
||
} else {
|
||
saveBtn.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
// Режимы
|
||
const MODES = [{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}];
|
||
let modeIdx=0;
|
||
const modeBtn = document.getElementById('modeBtn');
|
||
function mode(){ return MODES[modeIdx].id; }
|
||
modeBtn.onclick = () => {
|
||
playSound('click'); // Звук клика по кнопке режима
|
||
modeIdx=(modeIdx+1)%MODES.length; modeBtn.textContent=MODES[modeIdx].icon;
|
||
};
|
||
|
||
// День/ночь (автоматический цикл)
|
||
let isNightTime = false;
|
||
|
||
// Управление
|
||
const inp = { l:false, r:false, j:false, s:false };
|
||
function bindHold(el, key){
|
||
const down=(e)=>{ e.preventDefault(); inp[key]=true; };
|
||
const up=(e)=>{ e.preventDefault(); inp[key]=false; };
|
||
el.addEventListener('pointerdown', down);
|
||
el.addEventListener('pointerup', up);
|
||
el.addEventListener('pointerleave', up);
|
||
}
|
||
const leftBtn = document.getElementById('left');
|
||
const rightBtn = document.getElementById('right');
|
||
const jumpBtn = document.getElementById('jump');
|
||
const downBtn = document.getElementById('down');
|
||
|
||
if(leftBtn) bindHold(leftBtn,'l');
|
||
if(rightBtn) bindHold(rightBtn,'r');
|
||
if(jumpBtn) bindHold(jumpBtn,'j');
|
||
if(downBtn) bindHold(downBtn,'s');
|
||
|
||
window.addEventListener('keydown', (e)=>{
|
||
if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=true;
|
||
if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=true;
|
||
if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=true;
|
||
if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=true;
|
||
});
|
||
window.addEventListener('keyup', (e)=>{
|
||
if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=false;
|
||
if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=false;
|
||
if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=false;
|
||
if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=false;
|
||
});
|
||
|
||
// Лодка
|
||
const boat = {
|
||
x: 0, y: 0,
|
||
w: 34, h: 34,
|
||
vx: 0, vy: 0,
|
||
active: false,
|
||
inWater: false
|
||
};
|
||
|
||
// Функция для расчёта урона с учётом брони
|
||
function calculateDamage(baseDamage) {
|
||
// Броня снижает урон пропорционально
|
||
// armor: 0 = без брони (100% урона)
|
||
// armor: 0.5 = железная броня (50% урона)
|
||
const reduction = player.armor;
|
||
const actualDamage = baseDamage * (1 - reduction);
|
||
console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1));
|
||
return actualDamage;
|
||
}
|
||
|
||
// Игрок
|
||
const player = {
|
||
x: 6*TILE, y: 0*TILE,
|
||
w: 34, h: 34,
|
||
vx: 0, vy: 0,
|
||
grounded: false,
|
||
inWater: false,
|
||
headInWater: false,
|
||
hp: 100,
|
||
hunger: 100,
|
||
o2: 100,
|
||
invuln: 0,
|
||
slowTimer: 0, // яд скорпиона — замедление
|
||
fallStartY: 0,
|
||
lastStepTime: 0,
|
||
sleeping: false,
|
||
inBoat: false,
|
||
armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
|
||
equippedArmor: null, // Тип надетой брони
|
||
xp: 0,
|
||
level: 1
|
||
};
|
||
|
||
// Сохраняем начальную позицию для возрождения
|
||
const spawnPoint = { x: 6*TILE, y: 0*TILE };
|
||
|
||
// Система дропов с мобов
|
||
const drops = []; // {x, y, vy, item, qty, age}
|
||
let levelUpPopup = null; // {text, timer}
|
||
|
||
function xpForLevel(lv) {
|
||
if (lv <= 1) return 0;
|
||
const thresholds = [0, 50, 150, 300, 500, 800, 1200, 1700, 2300, 3000];
|
||
if (lv - 1 < thresholds.length) return thresholds[lv - 1];
|
||
return Math.floor(3000 + (lv - 10) * (lv - 10) * 50 + (lv - 10) * 200);
|
||
}
|
||
|
||
function getMobLoot(kind) {
|
||
const table = {
|
||
chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}],
|
||
pig: [{item:'meat',min:1,max:2,chance:1}],
|
||
zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}],
|
||
skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}],
|
||
creeper: [{item:'gunpowder',min:1,max:2,chance:1}],
|
||
scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}],
|
||
polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}],
|
||
slime: [{item:'slime_ball',min:1,max:2,chance:1}],
|
||
eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}]
|
||
};
|
||
return table[kind] || [];
|
||
}
|
||
|
||
function getMobXP(kind) {
|
||
const xpTable = { chicken:3, pig:5, zombie:10, skeleton:12, creeper:15, scorpion:8, polar_bear:20, slime:5, eagle:15 };
|
||
return xpTable[kind] || 0;
|
||
}
|
||
|
||
function spawnDrops(mx, my, kind) {
|
||
const loot = getMobLoot(kind);
|
||
for (const entry of loot) {
|
||
if (Math.random() > entry.chance) continue;
|
||
const qty = entry.min + Math.floor(Math.random() * (entry.max - entry.min + 1));
|
||
if (qty <= 0) continue;
|
||
drops.push({
|
||
x: mx + (Math.random() - 0.5) * 20,
|
||
y: my + (Math.random() - 0.5) * 10,
|
||
vy: -1 - Math.random() * 2,
|
||
item: entry.item,
|
||
qty: qty,
|
||
age: 0
|
||
});
|
||
}
|
||
}
|
||
|
||
const LEVEL_UNLOCKS = {
|
||
2: 'Каменные инструменты, Лодка',
|
||
3: 'Железные инструменты, Печь',
|
||
4: 'Лук и стрелы',
|
||
5: 'Железная броня',
|
||
6: 'Золотая броня',
|
||
7: 'Алмазные инструменты',
|
||
8: 'TNT'
|
||
};
|
||
|
||
function grantXP(amount) {
|
||
player.xp += amount;
|
||
while (player.xp >= xpForLevel(player.level + 1)) {
|
||
player.level++;
|
||
const unlock = LEVEL_UNLOCKS[player.level] || '';
|
||
levelUpPopup = { text: '⭐ Уровень ' + player.level + '!' + (unlock ? ' ' + unlock : ''), timer: 240 };
|
||
}
|
||
}
|
||
|
||
function pickupDrops() {
|
||
for (let i = drops.length - 1; i >= 0; i--) {
|
||
const d = drops[i];
|
||
const dx = player.x + player.w/2 - d.x;
|
||
const dy = player.y + player.h/2 - d.y;
|
||
if (dx*dx + dy*dy < 30*30) {
|
||
if (!inv[d.item]) inv[d.item] = 0;
|
||
inv[d.item] += d.qty;
|
||
drops.splice(i, 1);
|
||
rebuildHotbar();
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawDrops(ctx) {
|
||
for (let i = drops.length - 1; i >= 0; i--) {
|
||
const d = drops[i];
|
||
d.age++;
|
||
if (d.age > 3600) { drops.splice(i, 1); continue; } // 60 sec at 60fps
|
||
// Bounce animation
|
||
const bounce = Math.abs(Math.sin(d.age * 0.05)) * 6;
|
||
const dy = d.y - bounce;
|
||
const sx = d.x - camX;
|
||
const sy = dy - camY;
|
||
// Skip if off screen
|
||
if (sx < -40 || sx > W + 40 || sy < -40 || sy > H + 40) continue;
|
||
// Glow effect
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.3;
|
||
ctx.fillStyle = '#ffff00';
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy, 14, 0, Math.PI*2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
// Item icon
|
||
ctx.save();
|
||
ctx.font = '16px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
const itemDef = ITEMS[d.item];
|
||
const icon = itemDef ? itemDef.icon : '🎁';
|
||
const label = d.qty > 1 ? icon + '×' + d.qty : icon;
|
||
ctx.fillText(label, sx, sy);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
function drawLevelUpPopup(ctx) {
|
||
if (!levelUpPopup) return;
|
||
levelUpPopup.timer--;
|
||
if (levelUpPopup.timer <= 0) { levelUpPopup = null; return; }
|
||
const alpha = Math.min(1, levelUpPopup.timer / 60);
|
||
ctx.save();
|
||
ctx.globalAlpha = alpha;
|
||
ctx.fillStyle = '#FFD700';
|
||
ctx.font = 'bold 36px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.strokeStyle = '#000';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeText(levelUpPopup.text, W/2, H/2 - 60);
|
||
ctx.fillText(levelUpPopup.text, W/2, H/2 - 60);
|
||
ctx.restore();
|
||
}
|
||
|
||
// Система сохранения игры (localStorage + in-memory fallback)
|
||
const SAVE_KEY = 'minegrechka_save';
|
||
let db = null; // Оставляем для совместимости, но не используем
|
||
let inMemorySave = null; // Запасное сохранение в памяти
|
||
|
||
// Инициализация (localStorage + in-memory fallback)
|
||
function initDB(){
|
||
return new Promise((resolve) => {
|
||
console.log('Используем localStorage для сохранений (sandbox режим)');
|
||
resolve(null);
|
||
});
|
||
}
|
||
|
||
// Детерминированный генератор псевдослучайных чисел на основе seed
|
||
function seededRandom(gx, gy){
|
||
const n = Math.sin(gx * 12.9898 + gy * 78.233 + worldSeed * 0.1) * 43758.5453;
|
||
return n - Math.floor(n);
|
||
}
|
||
|
||
function saveGame(){
|
||
const saveData = {
|
||
version: 2,
|
||
worldSeed: worldSeed,
|
||
player: {
|
||
x: player.x,
|
||
y: player.y,
|
||
hp: player.hp,
|
||
hunger: player.hunger,
|
||
o2: player.o2,
|
||
xp: player.xp,
|
||
level: player.level
|
||
},
|
||
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;
|
||
player.xp = saveData.player.xp || 0;
|
||
player.level = saveData.player.level || 1;
|
||
|
||
// Обновляем spawnPoint на позицию из сохранения
|
||
spawnPoint.x = player.x;
|
||
spawnPoint.y = player.y;
|
||
|
||
// Проверяем HP из сохранения - если <= 0, устанавливаем 100
|
||
const savedHP = saveData.player.hp;
|
||
console.log('Saved HP from file:', savedHP);
|
||
if(savedHP <= 0){
|
||
console.log('WARNING: Saved HP is <= 0, setting to 100!');
|
||
player.hp = 100;
|
||
} else {
|
||
player.hp = savedHP;
|
||
}
|
||
console.log('player HP after restore:', player.hp);
|
||
console.log('spawnPoint обновлён из сохранения: x=', spawnPoint.x, 'y=', spawnPoint.y);
|
||
} else {
|
||
console.log('No player data in save, setting default HP: 100');
|
||
player.hp = 100;
|
||
}
|
||
|
||
console.log('=== applySave END ===');
|
||
|
||
// Восстанавливаем инвентарь
|
||
if(saveData.inventory){
|
||
for(const key in saveData.inventory){
|
||
inv[key] = saveData.inventory[key];
|
||
}
|
||
}
|
||
|
||
// Восстанавливаем время
|
||
if(saveData.time !== undefined){
|
||
worldTime = saveData.time;
|
||
}
|
||
|
||
// Восстанавливаем день/ночь
|
||
if(saveData.isNight !== undefined){
|
||
isNightTime = saveData.isNight;
|
||
}
|
||
|
||
// Перегенерируем мир по seed
|
||
regenerateVisibleChunks();
|
||
|
||
// Применяем изменения (только для v2)
|
||
if(saveData.version === 2){
|
||
// Применяем блоки, установленные игроком
|
||
for(const block of saveData.placedBlocks){
|
||
setBlock(block.gx, block.gy, block.t, true);
|
||
}
|
||
|
||
// Применяем удалённые блоки
|
||
for(const block of saveData.removedBlocks){
|
||
removeBlock(block.gx, block.gy);
|
||
}
|
||
|
||
// Восстанавливаем массивы изменений
|
||
placedBlocks = saveData.placedBlocks || [];
|
||
removedBlocks = saveData.removedBlocks || [];
|
||
}
|
||
|
||
rebuildHotbar();
|
||
console.log('Игра загружена');
|
||
}
|
||
|
||
// Камера (двухосевая)
|
||
let camX=0, camY=0;
|
||
|
||
// День/ночь
|
||
let worldTime=0;
|
||
const DAY_LEN=360; // замедлил смену дня/ночи в 3 раза
|
||
|
||
// Облака
|
||
const clouds = Array.from({length:10}, ()=>({
|
||
x: Math.random()*2000,
|
||
y: -200 - Math.random()*260, // выше экрана, потому что мир по Y теперь двигается
|
||
w: 80+Math.random()*120,
|
||
s: 12+Math.random()*20
|
||
}));
|
||
|
||
// Дождь
|
||
let isRaining = false;
|
||
let rainIntensity = 0; // 0..1
|
||
const snowflakes = []; // снежинки для тундры
|
||
const MAX_SNOWFLAKES = 150;
|
||
const raindrops = [];
|
||
const MAX_RAINDROPS = 200;
|
||
|
||
// ==================== РОСТ КУЛЬТУР ====================
|
||
const growthTimers = {}; // ключ: "gx,gy" → { stage:0-3, growTimer:X }
|
||
|
||
// Старая функция заменена — теперь погода через биомы (см. weatherState выше)
|
||
// updateWeather(dt) вызывается из основного цикла — биом-зависимая
|
||
|
||
// Интеграция: определяем isRaining из weatherState для визуализации
|
||
function syncWeatherVisual() {
|
||
isRaining = (weatherState.type === 'rain' || weatherState.type === 'storm');
|
||
if (weatherState.type === 'clear') {
|
||
rainIntensity *= 0.95; // плавное затухание
|
||
if (rainIntensity < 0.01) rainIntensity = 0;
|
||
}
|
||
}
|
||
|
||
function updateRain(dt) {
|
||
syncWeatherVisual();
|
||
// Дождь
|
||
if ((weatherState.type === 'rain' || weatherState.type === 'storm') && rainIntensity < weatherState.intensity * 0.6) {
|
||
rainIntensity += dt * 0.3;
|
||
} else if (weatherState.type === 'clear' || weatherState.type === 'snow' || weatherState.type === 'fog') {
|
||
rainIntensity = Math.max(0, rainIntensity - dt * 0.5);
|
||
}
|
||
if (rainIntensity < 0.01) {
|
||
raindrops.length = 0;
|
||
} else {
|
||
const spawnRate = Math.floor(rainIntensity * 80 * dt);
|
||
for (let i = 0; i < spawnRate && raindrops.length < MAX_RAINDROPS; i++) {
|
||
raindrops.push({
|
||
x: camX + Math.random() * W,
|
||
y: camY - 20,
|
||
vy: 400 + Math.random() * 200,
|
||
len: 8 + Math.random() * 12
|
||
});
|
||
}
|
||
for (let i = raindrops.length - 1; i >= 0; i--) {
|
||
const d = raindrops[i];
|
||
d.y += d.vy * dt;
|
||
d.x -= 30 * dt;
|
||
if (d.y > camY + H + 20) raindrops.splice(i, 1);
|
||
}
|
||
}
|
||
// Снег
|
||
if (weatherState.type === 'snow') {
|
||
const spawnRate = Math.floor(weatherState.intensity * 30 * dt);
|
||
for (let i = 0; i < spawnRate && snowflakes.length < MAX_SNOWFLAKES; i++) {
|
||
snowflakes.push({
|
||
x: camX + Math.random() * W,
|
||
y: camY - 10,
|
||
vy: 40 + Math.random() * 60,
|
||
vx: (Math.random() - 0.5) * 30,
|
||
size: 2 + Math.random() * 3
|
||
});
|
||
}
|
||
}
|
||
for (let i = snowflakes.length - 1; i >= 0; i--) {
|
||
const s = snowflakes[i];
|
||
s.y += s.vy * dt;
|
||
s.x += s.vx * dt + Math.sin(s.y * 0.02) * 10 * dt;
|
||
if (s.y > camY + H + 20 || weatherState.type !== 'snow') snowflakes.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
function drawRain() {
|
||
// Дождь
|
||
if (raindrops.length > 0) {
|
||
ctx.save();
|
||
ctx.strokeStyle = (weatherState.type === 'storm') ? 'rgba(174,194,224,0.7)' : 'rgba(174,194,224,0.5)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
for (const d of raindrops) {
|
||
ctx.moveTo(d.x, d.y);
|
||
ctx.lineTo(d.x - 3, d.y + d.len);
|
||
}
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
// Снег
|
||
if (snowflakes.length > 0) {
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||
for (const s of snowflakes) {
|
||
ctx.beginPath();
|
||
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
// Гроза — вспышка
|
||
if (weatherState.type === 'storm' && Math.random() < 0.003) {
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||
ctx.fillRect(camX, camY, W, H);
|
||
ctx.restore();
|
||
}
|
||
// Туман — серый оверлей
|
||
if (weatherState.type === 'fog') {
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(200,200,200,0.4)';
|
||
ctx.fillRect(camX, camY, W, H);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// Частицы (взрыв)
|
||
const parts = [];
|
||
function spawnExplosion(x,y, power){
|
||
const n = Math.floor(16 + power*10);
|
||
for(let i=0;i<n;i++){
|
||
parts.push({
|
||
x, y,
|
||
vx:(Math.random()-0.5)*(300+power*200),
|
||
vy:(Math.random()-0.5)*(300+power*200),
|
||
t:0.7, c:'#ffa500'
|
||
});
|
||
}
|
||
}
|
||
|
||
// Сущности: животные + зомби
|
||
class Entity {
|
||
constructor(x,y,w,h){ this.x=x; this.y=y; this.w=w; this.h=h; this.vx=0; this.vy=0; this.hp=3; this.grounded=false; this.inWater=false; this.aiT=0; this.dir=1; }
|
||
}
|
||
class Pig extends Entity{
|
||
constructor(x,y){ super(x,y,34,24); this.kind='pig'; this.hp=2; }
|
||
}
|
||
class Chicken extends Entity{
|
||
constructor(x,y){ super(x,y,26,22); this.kind='chicken'; this.hp=1; }
|
||
}
|
||
class Zombie extends Entity{
|
||
constructor(x,y){ super(x,y,34,50); this.kind='zombie'; this.hp=4; this.speed=80+Math.random()*40; }
|
||
}
|
||
class Creeper extends Entity{
|
||
constructor(x,y){ super(x,y,34,50); this.kind='creeper'; this.hp=4; this.speed=60+Math.random()*30; this.fuse=3.2; }
|
||
}
|
||
class Skeleton extends Entity{
|
||
constructor(x,y){ super(x,y,34,50); this.kind='skeleton'; this.hp=4; this.speed=70+Math.random()*30; this.shootCooldown=0; }
|
||
}
|
||
const mobs = [];
|
||
const projectiles = []; // стрелы в полёте
|
||
let spawnT=0;
|
||
|
||
// Физика (стабильная, без «телепортов»)
|
||
const GRAV = 2200;
|
||
const GRAV_WATER = 550;
|
||
const MOVE = 320;
|
||
const JUMP = 760;
|
||
|
||
function isWaterAt(px, py){
|
||
const gx = Math.floor(px / TILE);
|
||
const gy = Math.floor(py / TILE);
|
||
const b = getBlock(gx, gy);
|
||
return !!(b && b.t === 'water');
|
||
}
|
||
|
||
function updateWaterFlag(e){
|
||
const cx = e.x + e.w/2;
|
||
const wasInWater = e.inWater;
|
||
|
||
// В воде, если в воде хотя бы центр/ноги (чтобы корректно работать у поверхности)
|
||
const mid = isWaterAt(cx, e.y + e.h/2);
|
||
const feet = isWaterAt(cx, e.y + e.h - 2);
|
||
e.inWater = mid || feet;
|
||
|
||
// Голова под водой — для кислорода/урона
|
||
e.headInWater = isWaterAt(cx, e.y + 4);
|
||
|
||
// Звук при падении в воду
|
||
if(e === player && !wasInWater && e.inWater && e.vy > 100){
|
||
playSound('splash');
|
||
}
|
||
}
|
||
|
||
function resolveY(e){
|
||
// Всегда пересчитываем grounded (не держим "липким")
|
||
e.grounded = false;
|
||
|
||
const x1 = e.x + 2;
|
||
const x2 = e.x + e.w - 2;
|
||
|
||
// Проверяем, находится ли игрок на лестнице (по центру)
|
||
const cx = e.x + e.w/2;
|
||
const cy = e.y + e.h/2;
|
||
const gx = Math.floor(cx / TILE);
|
||
const gy = Math.floor(cy / TILE);
|
||
const b = getBlock(gx, gy);
|
||
const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
|
||
|
||
// Если на лестнице - можно двигаться вверх/вниз
|
||
if(onLadder){
|
||
e.grounded = true;
|
||
|
||
// Если нажимаем прыжок на лестнице - поднимаемся
|
||
if(inp.j){
|
||
e.vy = -200;
|
||
}
|
||
// Если нажимаем вниз - спускаемся
|
||
else if(inp.s){
|
||
e.vy = 100;
|
||
}
|
||
// Иначе - остаёмся на месте (нет гравитации)
|
||
else {
|
||
e.vy = 0;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа)
|
||
const leftGX = Math.floor((e.x - 4) / TILE);
|
||
const rightGX = Math.floor((e.x + e.w + 4) / TILE);
|
||
const playerGY = Math.floor((e.y + e.h/2) / TILE);
|
||
|
||
const leftBlock = getBlock(leftGX, playerGY);
|
||
const rightBlock = getBlock(rightGX, playerGY);
|
||
const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
|
||
const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
|
||
|
||
// Если рядом есть лестница и игрок прыгает - притягиваем к ней
|
||
if((leftLadder || rightLadder) && inp.j && e.vy < 0){
|
||
// Перемещаем игрока к лестнице
|
||
if(leftLadder && e.x > leftGX * TILE + TILE/2){
|
||
e.x = leftGX * TILE + TILE/2 - e.w/2;
|
||
} else if(rightLadder && e.x < rightGX * TILE + TILE/2){
|
||
e.x = rightGX * TILE + TILE/2 - e.w/2;
|
||
}
|
||
e.grounded = true;
|
||
e.vy = -150; // меньший прыжок при запрыгивании на лестницу
|
||
return;
|
||
}
|
||
|
||
// 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами
|
||
// Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла.
|
||
if(e.vy >= 0){
|
||
const probeY = e.y + e.h + 1;
|
||
const gy = Math.floor(probeY / TILE);
|
||
const gxA = Math.floor(x1 / TILE);
|
||
const gxB = Math.floor(x2 / TILE);
|
||
|
||
if(isSolid(gxA, gy) || isSolid(gxB, gy)){
|
||
e.y = gy * TILE - e.h; // прижимаем к полу
|
||
e.vy = 0;
|
||
e.grounded = true;
|
||
|
||
// урон от падения — только игроку и только не в воде
|
||
if(e === player && !player.inWater){
|
||
const fallTiles = (e.y - e.fallStartY) / TILE;
|
||
if(fallTiles > 6) {
|
||
const damage = calculateDamage((fallTiles - 6) * 10);
|
||
player.hp -= damage;
|
||
}
|
||
}
|
||
if(e === player) e.fallStartY = e.y;
|
||
}
|
||
}
|
||
|
||
// 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним
|
||
if(e.vy < 0 && e === player){
|
||
const gy = Math.floor(e.y / TILE);
|
||
const gxA = Math.floor(x1 / TILE);
|
||
const gxB = Math.floor(x2 / TILE);
|
||
|
||
// Проверяем, есть ли блок рядом с игроком
|
||
if((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)){
|
||
e.y = (gy + 1) * TILE;
|
||
e.vy = 0;
|
||
e.grounded = true;
|
||
if(e === player) e.fallStartY = e.y;
|
||
console.log("Jumped onto block!");
|
||
}
|
||
}
|
||
|
||
// 2) Если движемся вверх — проверяем потолок
|
||
if(e.vy < 0){
|
||
const gy = Math.floor(e.y / TILE);
|
||
const gxA = Math.floor(x1 / TILE);
|
||
const gxB = Math.floor(x2 / TILE);
|
||
if(isSolid(gxA, gy) || isSolid(gxB, gy)){
|
||
e.y = (gy + 1) * TILE;
|
||
e.vy = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
function resolveX(e){
|
||
const y1 = e.y + 2;
|
||
const y2 = e.y + e.h - 2;
|
||
|
||
// Проверяем, находимся ли мы на лестнице
|
||
const cx = e.x + e.w/2;
|
||
const cy = e.y + e.h/2;
|
||
const gx = Math.floor(cx / TILE);
|
||
const gy = Math.floor(cy / TILE);
|
||
const b = getBlock(gx, gy);
|
||
const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
|
||
|
||
if(e.vx > 0){
|
||
const gx = Math.floor((e.x + e.w)/TILE);
|
||
const gyA = Math.floor(y1/TILE);
|
||
const gyB = Math.floor(y2/TILE);
|
||
const solidA = isSolid(gx, gyA);
|
||
const solidB = isSolid(gx, gyB);
|
||
|
||
if(solidA || solidB){
|
||
e.x = gx*TILE - e.w;
|
||
e.vx = 0;
|
||
}
|
||
} else if(e.vx < 0){
|
||
const gx = Math.floor(e.x/TILE);
|
||
const gyA = Math.floor(y1/TILE);
|
||
const gyB = Math.floor(y2/TILE);
|
||
const solidA = isSolid(gx, gyA);
|
||
const solidB = isSolid(gx, gyB);
|
||
|
||
if(solidA || solidB){
|
||
e.x = (gx+1)*TILE;
|
||
e.vx = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
// TNT логика: цепь + усиление
|
||
const activeTNT = new Set(); // хранит key
|
||
function activateTNT(b, fuse=3.2){
|
||
if(b.dead) return;
|
||
if(b.active) return;
|
||
b.active=true;
|
||
b.fuse=fuse;
|
||
activeTNT.add(k(b.gx,b.gy));
|
||
}
|
||
|
||
function explodeAt(gx,gy){
|
||
const center = getBlock(gx,gy);
|
||
if(!center) return;
|
||
|
||
// усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу»
|
||
let bonus = 0;
|
||
for(let x=gx-2; x<=gx+2; x++){
|
||
for(let y=gy-2; y<=gy+2; y++){
|
||
const b = getBlock(x,y);
|
||
if(b && !b.dead && b.t==='tnt' && !(x===gx && y===gy)){
|
||
bonus += 0.8;
|
||
activateTNT(b, 0.12); // цепь
|
||
}
|
||
}
|
||
}
|
||
|
||
const power = 1 + bonus; // условная мощность
|
||
const radius = 3.2 + bonus*0.7; // радиус разрушения в тайлах
|
||
const dmgR = 150 + bonus*60; // радиус урона в пикселях
|
||
|
||
removeBlock(gx,gy);
|
||
activeTNT.delete(k(gx,gy));
|
||
playSound('explode1'); // Звук взрыва
|
||
spawnExplosion(gx*TILE + TILE/2, gy*TILE + TILE/2, power);
|
||
|
||
for(let x=Math.floor(gx-radius); x<=Math.ceil(gx+radius); x++){
|
||
for(let y=Math.floor(gy-radius); y<=Math.ceil(gy+radius); y++){
|
||
const d = Math.hypot(x-gx, y-gy);
|
||
if(d > radius) continue;
|
||
const b = getBlock(x,y);
|
||
if(!b || b.dead) continue;
|
||
if(BLOCKS[b.t].fluid) continue;
|
||
if(BLOCKS[b.t].unbreakable) continue;
|
||
if(b.t==='tnt') { activateTNT(b, 0.12); continue; }
|
||
removeBlock(x,y);
|
||
if(inv[b.t] !== undefined && Math.random()<0.20) inv[b.t]++; // немного дропа
|
||
}
|
||
}
|
||
rebuildHotbar();
|
||
|
||
// урон
|
||
const hurt = (e)=>{
|
||
const dx = (e.x+e.w/2) - (gx*TILE+TILE/2);
|
||
const dy = (e.y+e.h/2) - (gy*TILE+TILE/2);
|
||
const dist = Math.hypot(dx,dy);
|
||
if(dist < dmgR){
|
||
const dmg = (dmgR - dist) * 0.06 * power;
|
||
if(e === player) {
|
||
const actualDamage = calculateDamage(dmg);
|
||
player.hp -= actualDamage;
|
||
} else {
|
||
e.hp -= dmg;
|
||
}
|
||
e.vx += (dx/dist || 0) * 600;
|
||
e.vy -= 320;
|
||
}
|
||
};
|
||
hurt(player);
|
||
mobs.forEach(hurt);
|
||
}
|
||
|
||
// Взаимодействие мышь/тап
|
||
const mouse = { x:null, y:null };
|
||
canvas.addEventListener('pointermove', (e)=>{
|
||
const r = canvas.getBoundingClientRect();
|
||
mouse.x = e.clientX - r.left;
|
||
mouse.y = e.clientY - r.top;
|
||
});
|
||
|
||
canvas.addEventListener('pointerdown', (e)=>{
|
||
if(craftOpen) return;
|
||
if(player.hp<=0) return;
|
||
|
||
const r = canvas.getBoundingClientRect();
|
||
const sx = e.clientX - r.left;
|
||
const sy = e.clientY - r.top;
|
||
|
||
const wx = sx + camX;
|
||
const wy = sy + camY;
|
||
|
||
const gx = Math.floor(wx / TILE);
|
||
const gy = Math.floor(wy / TILE);
|
||
|
||
// Пробуждение: клик по любой кровати когда спишь
|
||
const b = getBlock(gx,gy);
|
||
if(player.sleeping && b && b.t==='bed'){
|
||
player.sleeping = false;
|
||
return;
|
||
}
|
||
|
||
if(player.sleeping) return; // Нельзя взаимодействовать во время сна
|
||
|
||
// Клик по печи — открываем панель обжига
|
||
if(b && b.t === 'furnace' && mode() === 'mine'){
|
||
openFurnaceUI(gx, gy);
|
||
return;
|
||
}
|
||
|
||
// клик по мобу (в режиме mine)
|
||
if(mode()==='mine'){
|
||
// Check all mobs (local + server-spawned) using getAllMobs
|
||
const allClickMobs = getAllMobs();
|
||
for(let i = allClickMobs.length - 1; i >= 0; i--){
|
||
const m = allClickMobs[i];
|
||
if(m.dead) continue;
|
||
if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){
|
||
let dmg = 1;
|
||
const swordTypes = ['iron_sword','stone_sword','wood_sword'];
|
||
for (const st of swordTypes) {
|
||
if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
|
||
}
|
||
m.hp -= dmg;
|
||
m.vx += (m.x - player.x) * 2;
|
||
m.vy -= 200;
|
||
playSound('attack');
|
||
// Server-spawned mob: emit hurt to server for relay, handle death locally
|
||
if(m.id !== undefined && isMultiplayer){
|
||
socket.emit('mob_hurt', { id: m.id, dmg });
|
||
if(m.hp <= 0){
|
||
socket.emit('mob_died', { id: m.id });
|
||
}
|
||
}
|
||
if(m.hp<=0){
|
||
if(m.kind === 'chicken') playSound('hurt_chicken');
|
||
spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind);
|
||
grantXP(getMobXP(m.kind));
|
||
// Remove from the correct array
|
||
if(m.id !== undefined){
|
||
serverMobs.delete(m.id);
|
||
} else {
|
||
const localIdx = mobs.indexOf(m);
|
||
if(localIdx >= 0) mobs.splice(localIdx, 1);
|
||
}
|
||
rebuildHotbar();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Лук — стреляем стрелой
|
||
if(selected === 'bow' && inv.bow > 0 && inv.arrow > 0){
|
||
const aimX = wx - player.x - player.w/2;
|
||
const aimY = wy - player.y - player.h/2;
|
||
const angle = Math.atan2(aimY, aimX);
|
||
projectiles.push({
|
||
x: player.x + player.w/2,
|
||
y: player.y + player.h/3,
|
||
vx: Math.cos(angle) * 550,
|
||
vy: Math.sin(angle) * 550,
|
||
dmg: 10,
|
||
owner: 'player',
|
||
life: 4
|
||
});
|
||
inv.arrow--;
|
||
useTool('bow');
|
||
playSound('hit1');
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
|
||
// еда (предмет)
|
||
if(ITEMS[selected] && inv[selected]>0){
|
||
const it = ITEMS[selected];
|
||
if(player.hp < 100 || player.hunger < 100){
|
||
playSound('eat1');
|
||
player.hunger = Math.min(100, player.hunger + it.food);
|
||
player.hp = Math.min(100, player.hp + 15);
|
||
inv[selected]--;
|
||
rebuildHotbar();
|
||
}
|
||
return;
|
||
}
|
||
|
||
//Посадка семян на грядку
|
||
if(b && b.t === 'farmland' && mode()==='build'){
|
||
const seedMap = { wheat: 'wheat_stage0', carrot: 'carrot_stage0', potato: 'potato_stage0' };
|
||
if(seedMap[selected] && inv[selected] > 0){
|
||
inv[selected]--;
|
||
const cropType = seedMap[selected];
|
||
setBlock(gx, gy-1, cropType);
|
||
growthTimers[gx+','+(gy-1)] = { stage:0, growTimer: 10+Math.random()*5 };
|
||
sendBlockChange(gx, gy-1, cropType, 'add');
|
||
playSound('cloth1');
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Мотыга — превращает grass/dirt в farmland (в любом режиме)
|
||
if(selected === 'hoe' && inv.hoe > 0 && b){
|
||
if(b.t === 'grass' || b.t === 'dirt'){
|
||
setBlock(gx, gy, 'farmland');
|
||
sendBlockChange(gx, gy, b.t, 'remove');
|
||
sendBlockChange(gx, gy, 'farmland', 'add');
|
||
useTool('hoe');
|
||
playSound('cloth1');
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// жарка на костре: выбран meat + клик по campfire
|
||
if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){
|
||
playSound('fire'); // Звук при жарке на костре
|
||
inv.meat--; inv.cooked++;
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
|
||
// Сон на кровати: клик по bed
|
||
if(b && b.t==='bed' && isNight()){
|
||
player.sleeping = true;
|
||
saveGame(); // Сохраняем при отходе ко сну
|
||
return;
|
||
}
|
||
|
||
if(mode()==='mine'){
|
||
if(!b) return;
|
||
if(BLOCKS[b.t].fluid) return;
|
||
// Клик на урожайную культуру — сбор
|
||
if(BLOCKS[b.t].harvestable){
|
||
const hInfo = BLOCKS[b.t];
|
||
inv[hInfo.harvestItem] = (inv[hInfo.harvestItem]||0) + hInfo.harvestQty;
|
||
removeBlock(gx, gy);
|
||
sendBlockChange(gx, gy, b.t, 'remove');
|
||
delete growthTimers[gx+','+gy];
|
||
playSound('cloth1');
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
if(BLOCKS[b.t].decor) return;
|
||
|
||
if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу
|
||
|
||
const removed = removeBlock(gx,gy);
|
||
if(removed){
|
||
inv[removed.t] = (inv[removed.t]||0) + 1;
|
||
|
||
// Тратим прочность кирки (если есть в инвентаре)
|
||
const pickTypes = ['iron_pickaxe','stone_pickaxe','wood_pickaxe'];
|
||
for (const pt of pickTypes) {
|
||
if (inv[pt] > 0) {
|
||
const broke = useTool(pt);
|
||
if (broke) playSound('cloth1'); // звук поломки
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Отправляем изменение блока на сервер
|
||
sendBlockChange(gx, gy, removed.t, 'remove');
|
||
|
||
// Звуки при добыче блоков
|
||
if(removed.t === 'glass') playSound('glass1');
|
||
else if(removed.t === 'sand') playSound('sand1');
|
||
else if(removed.t === 'snow') playSound('snow1');
|
||
else if(removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1');
|
||
else if(removed.t === 'wood') playSound('wood1');
|
||
else playSound('cloth1');
|
||
|
||
rebuildHotbar();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if(mode()==='build'){
|
||
if(inv[selected] <= 0) return;
|
||
if(!BLOCKS[selected]) return;
|
||
if(b) return; // занято
|
||
|
||
// Проверяем, ставим ли лодку
|
||
if(selected === 'boat'){
|
||
// Лодку можно ставить только на воду
|
||
const waterBelow = getBlock(gx, gy+1);
|
||
if(!waterBelow || waterBelow.t !== 'water'){
|
||
return;
|
||
}
|
||
|
||
// Создаём лодку
|
||
boat.x = gx * TILE;
|
||
boat.y = gy * TILE;
|
||
boat.vx = 0;
|
||
boat.vy = 0;
|
||
boat.active = true;
|
||
boat.inWater = true;
|
||
|
||
// Сажаем игрока в лодку
|
||
player.inBoat = true;
|
||
player.x = boat.x;
|
||
player.y = boat.y;
|
||
player.vx = 0;
|
||
player.vy = 0;
|
||
|
||
playSound('splash');
|
||
inv[selected]--;
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
|
||
// запрет ставить в игрока
|
||
const bx = gx*TILE, by = gy*TILE;
|
||
const overlap = !(bx >= player.x+player.w || bx+TILE <= player.x || by >= player.y+player.h || by+TILE <= player.y);
|
||
if(overlap) return;
|
||
|
||
setBlock(gx,gy,selected, true); // true = блок установлен игроком
|
||
inv[selected]--;
|
||
|
||
// Отправляем изменение блока на сервер
|
||
sendBlockChange(gx, gy, selected, 'set');
|
||
|
||
// Звук при строительстве
|
||
if(selected === 'stone' || selected === 'brick') playSound('stone_build');
|
||
else if(selected === 'wood' || selected === 'planks') playSound('wood_build');
|
||
else if(selected === 'glass') playSound('glass1');
|
||
else if(selected === 'sand') playSound('sand1');
|
||
else if(selected === 'snow') playSound('snow1');
|
||
else if(selected === 'dirt' || selected === 'grass') playSound('cloth1');
|
||
|
||
rebuildHotbar();
|
||
return;
|
||
}
|
||
});
|
||
|
||
// ==================== БИОМЫ ====================
|
||
const BIOMES = {
|
||
plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, flowers:true, treeChance:0.12 },
|
||
desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, flowers:false, treeChance:0 },
|
||
tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, flowers:false, treeChance:0.06 },
|
||
swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, flowers:false, treeChance:0.10 },
|
||
mountains:{ name:'Горы', surface:'stone', subsurface:'stone', trees:false, flowers:false, treeChance:0 }
|
||
};
|
||
|
||
function getBiome(gx) {
|
||
const temp = Math.sin(gx*0.003 + worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + worldSeed*0.02)*0.3 + 0.5;
|
||
const humid = Math.sin(gx*0.004 + worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + worldSeed*0.02 + 2000)*0.3 + 0.5;
|
||
const mtVal = Math.sin(gx*0.001 + worldSeed*0.005)*0.5 + 0.5;
|
||
if (temp > 0.7) return 'desert';
|
||
if (temp < 0.3) return 'tundra';
|
||
if (humid > 0.7 && temp >= 0.3 && temp <= 0.7) return 'swamp';
|
||
if (temp >= 0.5 && temp <= 0.7 && mtVal > 0.75) return 'mountains';
|
||
return 'plains';
|
||
}
|
||
|
||
const biomeCache = {};
|
||
function getCachedBiome(gx) {
|
||
const chunk = Math.floor(gx / 8); // cache per 8-tile chunk for smoother biomes
|
||
if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8);
|
||
return biomeCache[chunk];
|
||
}
|
||
|
||
// ==================== ПОГОДА ====================
|
||
const weatherState = { type: 'clear', intensity: 0, timer: 0, duration: 180, nextChange: 120 + Math.random()*180 };
|
||
const BIOME_WEATHER = {
|
||
plains: { clear:0.50, rain:0.30, storm:0.10, snow:0, fog:0.10 },
|
||
desert: { clear:0.80, rain:0.05, storm:0, snow:0, fog:0.15 },
|
||
tundra: { clear:0.20, rain:0, storm:0.10, snow:0.60, fog:0.10 },
|
||
swamp: { clear:0.20, rain:0.30, storm:0.10, snow:0, fog:0.40 },
|
||
mountains:{ clear:0.40, rain:0.30, storm:0.10, snow:0.10, fog:0.10 }
|
||
};
|
||
|
||
function updateWeather(dt) {
|
||
weatherState.timer += dt;
|
||
if (weatherState.timer >= weatherState.nextChange) {
|
||
weatherState.timer = 0;
|
||
weatherState.nextChange = 60 + Math.random() * 240;
|
||
const biome = getCachedBiome(Math.floor(player.x / TILE));
|
||
const probs = BIOME_WEATHER[biome] || BIOME_WEATHER.plains;
|
||
const r = Math.random();
|
||
let cum = 0;
|
||
if ((cum += probs.clear) > r) { weatherState.type = 'clear'; }
|
||
else if ((cum += probs.rain) > r) { weatherState.type = 'rain'; }
|
||
else if ((cum += probs.storm) > r) { weatherState.type = 'storm'; }
|
||
else if ((cum += probs.snow) > r) { weatherState.type = 'snow'; }
|
||
else { weatherState.type = 'fog'; }
|
||
weatherState.duration = 60 + Math.random() * 300;
|
||
}
|
||
// Intensity interpolation
|
||
const target = (weatherState.type === 'clear') ? 0 : 1;
|
||
weatherState.intensity += (target - weatherState.intensity) * dt * 0.5;
|
||
}
|
||
|
||
function getWeatherSpeedMultiplier() {
|
||
if (weatherState.type === 'rain') return 0.85;
|
||
if (weatherState.type === 'snow') return 0.7;
|
||
if (weatherState.type === 'storm') return 0.85;
|
||
return 1;
|
||
}
|
||
|
||
function isOutdoorLight(lx, ly) {
|
||
// Check if position is outdoors (no block above)
|
||
const aboveGy = Math.floor(ly / TILE) - 1;
|
||
const aboveGx = Math.floor(lx / TILE);
|
||
const above = getBlock(aboveGx, aboveGy);
|
||
return !above || !BLOCKS[above.t]?.solid;
|
||
}
|
||
|
||
// ==================== СТРУКТУРЫ МИРА ====================
|
||
function placeStructure(startGx, startGy, pattern) {
|
||
for (let dy = 0; dy < pattern.length; dy++) {
|
||
for (let dx = 0; dx < pattern[dy].length; dx++) {
|
||
const bt = pattern[dy][dx];
|
||
if (bt && bt !== 'air') {
|
||
setBlock(startGx + dx, startGy + dy, bt);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Пирамида в пустыне
|
||
const PYRAMID_PATTERN = [
|
||
['sand','sand','sand','sand','sand','sand','sand'],
|
||
['sand','stone','stone','stone','stone','stone','sand'],
|
||
['sand','stone','air','air','air','stone','sand'],
|
||
['sand','stone','air','air','air','stone','sand'],
|
||
['sand','stone','air','air','air','stone','sand'],
|
||
['sand','sand','stone','stone','stone','sand','sand']
|
||
];
|
||
|
||
// Дом в равнине
|
||
const HOUSE_PATTERN = [
|
||
['air','planks','planks','planks','planks','air'],
|
||
['planks','air','air','air','air','planks'],
|
||
['planks','air','torch','air','air','planks'],
|
||
['planks','air','air','air','air','planks'],
|
||
['planks','planks','air','planks','planks','planks']
|
||
];
|
||
|
||
// Хижина в болоте
|
||
const HUT_PATTERN = [
|
||
['air','wood','wood','wood','air'],
|
||
['wood','air','air','air','wood'],
|
||
['wood','air','torch','air','wood'],
|
||
['moss','moss','air','moss','moss']
|
||
];
|
||
|
||
// ==================== ГЕНЕРАЦИЯ ====================
|
||
const generated = new Set(); // gx already generated
|
||
function surfaceGyAt(gx) {
|
||
const biome = getCachedBiome(gx);
|
||
// Base noise (same for all biomes)
|
||
const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8;
|
||
const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12;
|
||
const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6;
|
||
const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4;
|
||
const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5;
|
||
let h;
|
||
switch(biome) {
|
||
case 'desert':
|
||
h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); // flatter, slightly higher
|
||
break;
|
||
case 'tundra':
|
||
h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); // gentle rolling
|
||
break;
|
||
case 'swamp':
|
||
h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); // very flat, near sea level
|
||
h = Math.max(h, SEA_GY - 3); // never too deep
|
||
break;
|
||
case 'mountains':
|
||
h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); // tall peaks
|
||
break;
|
||
default: // plains
|
||
h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5);
|
||
}
|
||
return h;
|
||
}
|
||
|
||
function genColumn(gx) {
|
||
if(generated.has(gx)) return;
|
||
generated.add(gx);
|
||
|
||
const sgy = surfaceGyAt(gx);
|
||
const biome = getCachedBiome(gx);
|
||
|
||
// === Вода и поверхность ===
|
||
if(sgy > SEA_GY) {
|
||
// ниже уровня моря — заливаем водой
|
||
for(let gy = SEA_GY; gy < sgy; gy++) {
|
||
const blockType = (biome === 'swamp' && gy >= SEA_GY - 1) ? 'swamp_water' : 'water';
|
||
setBlock(gx, gy, blockType);
|
||
}
|
||
setBlock(gx, sgy, 'sand');
|
||
} else {
|
||
// поверхность
|
||
const b = BIOMES[biome];
|
||
setBlock(gx, sgy, b.surface);
|
||
// болото: случайные лужи болотной воды
|
||
if(biome === 'swamp' && seededRandom(gx*3, sgy) < 0.15) {
|
||
setBlock(gx, sgy-1, 'swamp_water');
|
||
}
|
||
// тундра: лёд на воде рядом
|
||
if(biome === 'tundra' && sgy === SEA_GY && seededRandom(gx, SEA_GY-1) < 0.3) {
|
||
setBlock(gx, SEA_GY-1, 'ice');
|
||
}
|
||
}
|
||
|
||
// === Подповерхностные слои ===
|
||
for(let gy = sgy+1; gy <= BEDROCK_GY; gy++) {
|
||
if(gy === BEDROCK_GY) { setBlock(gx,gy,'bedrock'); continue; }
|
||
|
||
let t = BIOMES[biome].subsurface;
|
||
|
||
// глубже — камень
|
||
if(gy > sgy + 3) t = 'stone';
|
||
|
||
// пустыня: sand глубже
|
||
if(biome === 'desert' && gy <= sgy + 6) t = 'sand';
|
||
|
||
// болото: глина ближе к поверхности
|
||
if(biome === 'swamp' && gy <= sgy + 2 && seededRandom(gx, gy) < 0.3) t = 'clay';
|
||
|
||
// горы: gravel
|
||
if(biome === 'mountains' && gy > sgy + 4 && seededRandom(gx, gy) < 0.12) t = 'gravel';
|
||
|
||
// общие руды
|
||
const depth = gy - sgy;
|
||
const r = seededRandom(gx, gy);
|
||
if(t === 'stone') {
|
||
if(r < 0.06) t = 'coal';
|
||
else if(r < 0.10) t = 'copper_ore';
|
||
else if(r < 0.13) t = 'iron_ore';
|
||
else if(depth > 40 && r < 0.145) t = 'gold_ore';
|
||
else if(depth > 70 && r < 0.152) t = 'diamond_ore';
|
||
}
|
||
|
||
setBlock(gx, gy, t);
|
||
}
|
||
|
||
// === Растительность и декор ===
|
||
const b = BIOMES[biome];
|
||
const top = getBlock(gx, sgy);
|
||
|
||
// цветы (только plain)
|
||
if(biome === 'plains' && top && top.t === 'grass' && seededRandom(gx, sgy-1) < 0.10) {
|
||
setBlock(gx, sgy-1, 'flower');
|
||
}
|
||
|
||
// деревья
|
||
if(b.trees && seededRandom(gx*7, sgy-2) < b.treeChance) {
|
||
if(biome === 'tundra') {
|
||
// ёлки (треугольные, 3-5 высоты)
|
||
const th = 3 + Math.floor(seededRandom(gx, sgy) * 3);
|
||
for(let i = 0; i < th; i++) setBlock(gx, sgy-1-i, 'wood');
|
||
// крона (треугольник)
|
||
for(let row = 0; row < th; row++) {
|
||
const w = Math.min(row + 1, 2);
|
||
for(let dx = -w; dx <= w; dx++) {
|
||
const ly = sgy-1-th+row;
|
||
if(ly >= 0) setBlock(gx+dx, ly, 'spruce_leaves');
|
||
}
|
||
}
|
||
} else if(biome === 'swamp') {
|
||
// болотное дерево (короче, с мхом)
|
||
setBlock(gx, sgy-1, 'wood');
|
||
setBlock(gx, sgy-2, 'moss');
|
||
setBlock(gx-1, sgy-2, 'leaves');
|
||
setBlock(gx+1, sgy-2, 'leaves');
|
||
} else {
|
||
// обычное дерево (plains)
|
||
setBlock(gx, sgy-1, 'wood');
|
||
setBlock(gx, sgy-2, 'wood');
|
||
setBlock(gx, sgy-3, 'leaves');
|
||
setBlock(gx-1, sgy-3, 'leaves');
|
||
setBlock(gx+1, sgy-3, 'leaves');
|
||
}
|
||
}
|
||
|
||
// кактусы (пустыня)
|
||
if(biome === 'desert' && seededRandom(gx*11, sgy) < 0.04) {
|
||
const ch = 1 + Math.floor(seededRandom(gx, sgy+1) * 2);
|
||
for(let i = 0; i < ch; i++) setBlock(gx, sgy-1-i, 'cactus');
|
||
}
|
||
|
||
// грибы (болото)
|
||
if(biome === 'swamp' && seededRandom(gx*13, sgy) < 0.06) {
|
||
setBlock(gx, sgy-1, 'mushroom');
|
||
}
|
||
|
||
// сухие кусты (пустыня)
|
||
if(biome === 'desert' && seededRandom(gx*17, sgy) < 0.05) {
|
||
setBlock(gx, sgy-1, 'dead_bush');
|
||
}
|
||
|
||
// === Структуры мира ===
|
||
// Пирамида в пустыне
|
||
if(biome === 'desert' && ((gx % 200 + 200) % 200) === 47 && sgy < SEA_GY && sgy > SEA_GY - 5) {
|
||
placeStructure(gx, sgy - 5, PYRAMID_PATTERN);
|
||
}
|
||
// Дом в равнине
|
||
if(biome === 'plains' && ((gx % 150 + 150) % 150) === 33 && sgy < SEA_GY) {
|
||
placeStructure(gx, sgy - 4, HOUSE_PATTERN);
|
||
}
|
||
// Хижина в болоте
|
||
if(biome === 'swamp' && ((gx % 180 + 180) % 180) === 55 && sgy < SEA_GY) {
|
||
placeStructure(gx, sgy - 3, HUT_PATTERN);
|
||
}
|
||
|
||
// Применяем серверные оверрайды для этой колонны
|
||
const colPrefix = gx + ',';
|
||
for (const [key, ov] of serverOverrides) {
|
||
if (!key.startsWith(colPrefix)) continue;
|
||
if (ov.op === 'remove') {
|
||
const b = grid.get(key);
|
||
if (b) { grid.delete(key); b.dead = true; }
|
||
} else if (ov.op === 'set') {
|
||
if (!grid.has(key)) {
|
||
const gy = parseInt(key.split(',')[1]);
|
||
const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
|
||
grid.set(key, nb);
|
||
blocks.push(nb);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Перегенерация видимых чанков (используется при загрузке сохранения)
|
||
function regenerateVisibleChunks(){
|
||
const gx0 = Math.floor(camX/TILE);
|
||
for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
|
||
// Принудительно перегенерируем колонну
|
||
generated.delete(gx);
|
||
genColumn(gx);
|
||
}
|
||
}
|
||
|
||
function ensureGenAroundCamera(){
|
||
const gx0 = Math.floor(camX/TILE);
|
||
for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
|
||
genColumn(gx);
|
||
}
|
||
}
|
||
|
||
// Лут с дерева/листвы: дерево -> wood; листья -> leaves
|
||
// (уже в mine добавляется inv[type] автоматически)
|
||
|
||
// Рисование костра: огонь поверх текстуры
|
||
function drawFire(wx,wy,now){
|
||
const baseX = wx;
|
||
const baseY = wy;
|
||
const flick = 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 -= 30*dt; m.vx *= 0.5; return; }
|
||
const dir = Math.sign((player.x) - m.x);
|
||
m.vx = dir * m.speed;
|
||
if(m.inWater && Math.random()<0.06) m.vy = -260;
|
||
// атака
|
||
if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 &&
|
||
Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 &&
|
||
player.invuln <= 0){
|
||
const damage = calculateDamage(15);
|
||
player.hp -= damage;
|
||
player.invuln = 0.8;
|
||
player.vx += dir*420;
|
||
player.vy -= 260;
|
||
playSound('hit1'); // Звук при атаке зомби
|
||
}
|
||
} else if(m.kind==='creeper'){
|
||
// активность ночью
|
||
const night = isNight();
|
||
if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
|
||
const dir = Math.sign((player.x) - m.x);
|
||
const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
|
||
|
||
// Движение к игроку
|
||
m.vx = dir * m.speed;
|
||
if(m.inWater && Math.random()<0.06) m.vy = -260;
|
||
|
||
// Взрыв если близко к игроку
|
||
if(dist < 60){
|
||
m.fuse -= dt;
|
||
if(m.fuse <= 0){
|
||
explodeAt(Math.floor((m.x+m.w/2)/TILE), Math.floor((m.y+m.h/2)/TILE));
|
||
m.hp = 0;
|
||
}
|
||
} else {
|
||
// Поджигаем если очень близко
|
||
if(dist < 40){
|
||
m.fuse = 0.5; // Быстрый взрыв
|
||
}
|
||
}
|
||
} else if(m.kind==='skeleton'){
|
||
// активность ночью
|
||
const night = isNight();
|
||
if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; }
|
||
const dir = Math.sign((player.x) - m.x);
|
||
const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2));
|
||
|
||
// Движение к игроку
|
||
m.vx = dir * m.speed;
|
||
if(m.inWater && Math.random()<0.06) m.vy = -260;
|
||
|
||
// Стрельба стрелами
|
||
m.shootCooldown -= dt;
|
||
if(dist < 300 && m.shootCooldown <= 0){
|
||
m.shootCooldown = 2.0;
|
||
const dx = (player.x+player.w/2) - (m.x+m.w/2);
|
||
const dy = (player.y+player.h/2) - (m.y+m.h/2);
|
||
const angle = Math.atan2(dy, dx);
|
||
const speed = 450;
|
||
projectiles.push({
|
||
x: m.x + m.w/2,
|
||
y: m.y + m.h/3,
|
||
vx: Math.cos(angle) * speed,
|
||
vy: Math.sin(angle) * speed,
|
||
dmg: 6,
|
||
owner: 'mob',
|
||
life: 3
|
||
});
|
||
}
|
||
} else if(m.kind==='scorpion') {
|
||
// Скорпион — бежит к игроку, яд (замедление)
|
||
const dir = Math.sign((player.x) - m.x);
|
||
m.vx = dir * m.speed;
|
||
if(m.inWater && Math.random()<0.06) m.vy = -260;
|
||
// Яд при касании — замедление на 3 сек
|
||
if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 &&
|
||
Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 &&
|
||
player.invuln <= 0){
|
||
const damage = calculateDamage(8);
|
||
player.hp -= damage;
|
||
player.invuln = 0.8;
|
||
player.slowTimer = 3; // замедление
|
||
player.vx += dir*300;
|
||
player.vy -= 200;
|
||
playSound('hit1');
|
||
}
|
||
} else if(m.kind==='polar_bear') {
|
||
// Белый медведь — нейтрален, атакует если ударили (hostile пока нет, атакует через proximity)
|
||
m.aiT -= dt;
|
||
if(m.aiT <= 0){
|
||
m.aiT = 2.0 + Math.random()*3;
|
||
m.dir = Math.random()<0.5 ? -1 : 1;
|
||
if(Math.random()<0.3) m.dir = 0;
|
||
}
|
||
m.vx = m.dir * m.speed;
|
||
if(m.inWater) m.vy = -120;
|
||
} else if(m.kind==='slime') {
|
||
// Слизь — прыгает к игроку
|
||
const dir = Math.sign((player.x+player.w/2) - (m.x+m.w/2));
|
||
m.aiT -= dt;
|
||
if(m.aiT <= 0){
|
||
m.aiT = 1.5 + Math.random()*1.5;
|
||
m.dir = dir;
|
||
// Прыжок
|
||
m.vy = -200;
|
||
}
|
||
m.vx = m.dir * m.speed;
|
||
} else if(m.kind==='eagle') {
|
||
// Орёл — летает, атакует пикированием
|
||
const dx = (player.x+player.w/2) - (m.x+m.w/2);
|
||
const dy = (player.y+player.h/2) - (m.y+m.h/2);
|
||
const dist = Math.hypot(dx, dy);
|
||
if(dist < 400) {
|
||
// Пикирует на игрока
|
||
m.vx = Math.sign(dx) * m.speed;
|
||
m.vy = dy > 0 ? 60 : -60; // летит вниз к игроку
|
||
} else {
|
||
// Патрулирует
|
||
m.aiT -= dt;
|
||
if(m.aiT <= 0){
|
||
m.aiT = 2+Math.random()*3;
|
||
m.dir = Math.random()<0.5 ? -1 : 1;
|
||
}
|
||
m.vx = m.dir * m.speed * 0.5;
|
||
m.vy = Math.sin(performance.now()/1000) * 30; // мягкое покачивание
|
||
}
|
||
// Атака при касании
|
||
if(dist < 30 && player.invuln <= 0){
|
||
const damage = calculateDamage(10);
|
||
player.hp -= damage;
|
||
player.invuln = 0.8;
|
||
player.vy -= 200;
|
||
playSound('hit1');
|
||
}
|
||
// Орёл не падает — летающий моб
|
||
m.vy *= 0.5;
|
||
m.grounded = false;
|
||
} else {
|
||
// животные (pig, chicken)
|
||
m.aiT -= dt;
|
||
if(m.aiT <= 0){
|
||
m.aiT = 1.8 + Math.random()*2.5;
|
||
m.dir = Math.random()<0.5 ? -1 : 1;
|
||
if(Math.random()<0.25) m.dir = 0;
|
||
}
|
||
m.vx = m.dir * (m.kind==='chicken' ? 55 : 40);
|
||
if(m.inWater) m.vy = -120;
|
||
}
|
||
|
||
// физика моба
|
||
const g = m.inWater ? GRAV_WATER : GRAV;
|
||
m.vy += g*dt;
|
||
|
||
m.y += m.vy*dt; m.grounded=false; resolveY(m);
|
||
m.x += m.vx*dt; resolveX(m);
|
||
}
|
||
|
||
function isNight(){
|
||
// Автоматический цикл: ночь когда worldTime > 0.5
|
||
return worldTime > 0.5;
|
||
}
|
||
|
||
// Respawn
|
||
document.getElementById('respawnBtn').onclick = async () => {
|
||
playSound('click'); // Звук клика по кнопке
|
||
|
||
console.log('=== RESPAWN CLICKED ===');
|
||
console.log('isMultiplayer:', isMultiplayer);
|
||
console.log('otherPlayers.size:', otherPlayers.size);
|
||
console.log('player.hp before respawn:', player.hp);
|
||
|
||
// В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
|
||
if (isMultiplayer && otherPlayers.size > 0) {
|
||
console.log('Мультиплеер режим - возрождение в начальной точке');
|
||
player.hp = 100;
|
||
player.hunger = 100;
|
||
player.o2 = 100;
|
||
player.vx = player.vy = 0;
|
||
player.invuln = 0;
|
||
player.x = spawnPoint.x;
|
||
player.y = spawnPoint.y;
|
||
player.fallStartY = player.y;
|
||
console.log('Возрождение в начальной точке, HP:', player.hp);
|
||
} else {
|
||
console.log('Одиночный режим - загружаем последнее сохранение');
|
||
// Одиночный режим - загружаем последнее сохранение
|
||
const loadedSave = await loadGame();
|
||
if(loadedSave){
|
||
await applySave(loadedSave);
|
||
console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
|
||
} else {
|
||
// Если сохранения нет, возрождаемся в начальной точке
|
||
player.hp = 100;
|
||
player.hunger = 100;
|
||
player.o2 = 100;
|
||
player.vx = player.vy = 0;
|
||
player.invuln = 0;
|
||
player.x = spawnPoint.x;
|
||
player.y = spawnPoint.y;
|
||
player.fallStartY = player.y;
|
||
console.log('Возрождение в начальной точке, HP:', player.hp);
|
||
}
|
||
}
|
||
|
||
console.log('player.hp after respawn logic:', player.hp);
|
||
console.log('Hiding death screen...');
|
||
deathEl.style.display='none';
|
||
console.log('=== RESPAWN END ===');
|
||
};
|
||
|
||
// Resize
|
||
function resize(){
|
||
W = gameEl.clientWidth;
|
||
H = gameEl.clientHeight;
|
||
canvas.width = W*dpr;
|
||
canvas.height = H*dpr;
|
||
lightC.width = W*dpr;
|
||
lightC.height = H*dpr;
|
||
ctx.setTransform(dpr,0,0,dpr,0,0);
|
||
}
|
||
window.addEventListener('resize', resize);
|
||
|
||
// init
|
||
resize();
|
||
rebuildHotbar();
|
||
|
||
// Инициализируем и загружаем сохранение
|
||
initDB().then(async () => {
|
||
// Пытаемся загрузить сохранённую игру
|
||
const loadedSave = await loadGame();
|
||
if(loadedSave){
|
||
await applySave(loadedSave);
|
||
console.log('Загружено сохранение, HP:', player.hp);
|
||
|
||
// Проверяем HP после загрузки - если <= 0, возрождаемся
|
||
if (player.hp <= 0) {
|
||
console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
|
||
player.hp = 100;
|
||
player.hunger = 100;
|
||
player.o2 = 100;
|
||
player.x = spawnPoint.x;
|
||
player.y = spawnPoint.y;
|
||
player.vx = player.vy = 0;
|
||
player.invuln = 0;
|
||
player.fallStartY = player.y;
|
||
}
|
||
} else {
|
||
console.log('Сохранение не найдено, начинаем новую игру');
|
||
|
||
// Инициализируем игрока для новой игры
|
||
player.hp = 100;
|
||
player.hunger = 100;
|
||
player.o2 = 100;
|
||
player.vx = player.vy = 0;
|
||
player.invuln = 0;
|
||
|
||
// старт — на поверхности (используем ту же логику что и в world_state)
|
||
const startGX = 6;
|
||
for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
|
||
const surfaceY = surfaceGyAt(startGX);
|
||
let safeGY = surfaceY - 1;
|
||
const aboveBlock = getBlock(startGX, surfaceY - 1);
|
||
if (aboveBlock && aboveBlock.t === 'water') {
|
||
for (let gy = SEA_GY - 1; gy >= 0; gy--) {
|
||
const b = getBlock(startGX, gy);
|
||
if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
|
||
safeGY = gy - 1;
|
||
break;
|
||
}
|
||
}
|
||
player.y = safeGY * TILE;
|
||
player.x = startGX * TILE;
|
||
player.fallStartY = player.y;
|
||
|
||
// Обновляем spawnPoint, чтобы возрождение было на поверхности
|
||
spawnPoint.x = player.x;
|
||
spawnPoint.y = player.y;
|
||
|
||
console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', player.y, 'player.hp=', player.hp);
|
||
console.log('spawnPoint обновлён: x=', spawnPoint.x, 'y=', spawnPoint.y);
|
||
|
||
// Генерируем карту вокруг стартовой позиции при инициализации
|
||
for(let gx = startGX - 50; gx <= startGX + 50; gx++){
|
||
genColumn(gx);
|
||
}
|
||
}
|
||
|
||
// Автосейв при скрытии страницы (защита от потери прогресса)
|
||
document.addEventListener('visibilitychange', () => {
|
||
if(document.hidden){
|
||
saveGame();
|
||
}
|
||
});
|
||
|
||
// Автосейв перед закрытием страницы (защита от потери прогресса)
|
||
window.addEventListener('beforeunload', () => {
|
||
saveGame();
|
||
});
|
||
}).catch(err => {
|
||
console.error('Ошибка инициализации:', err);
|
||
// При ошибке начинаем новую игру
|
||
const startGX = 6;
|
||
genColumn(startGX);
|
||
player.y = (surfaceGyAt(startGX)-1)*TILE;
|
||
player.fallStartY = player.y;
|
||
|
||
for(let gx = startGX - 50; gx <= startGX + 50; gx++){
|
||
genColumn(gx);
|
||
}
|
||
});
|
||
|
||
// main loop
|
||
let last = performance.now();
|
||
let prevJump = false;
|
||
// При возврате на вкладку — сбрасываем last чтобы не было скачка dt
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (!document.hidden) last = performance.now();
|
||
});
|
||
function loop(now){
|
||
const rawDt = Math.min(0.05, (now-last)/1000);
|
||
last = now;
|
||
// Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки
|
||
const PHYSICS_STEP = 0.016;
|
||
const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
|
||
const dt = rawDt / steps;
|
||
|
||
const jumpPressed = inp.j && !prevJump;
|
||
prevJump = inp.j;
|
||
|
||
// Ускорение времени во время сна
|
||
if(player.sleeping && isNight()){
|
||
worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее
|
||
// Восстанавливаем здоровье во время сна
|
||
player.hp = Math.min(100, player.hp + dt * 20);
|
||
// Автоматическое пробуждение когда наступает день
|
||
if(!isNight()){
|
||
player.sleeping = false;
|
||
}
|
||
} else {
|
||
worldTime += dt / DAY_LEN;
|
||
}
|
||
if(worldTime >= 1) worldTime -= 1;
|
||
|
||
// камера следует за игроком по X/Y
|
||
camX = Math.floor((player.x + player.w/2) - W/2);
|
||
camY = Math.floor((player.y + player.h/2) - H/2);
|
||
|
||
ensureGenAroundCamera();
|
||
|
||
// clouds parallax
|
||
for(const c of clouds){
|
||
c.x -= c.s * dt;
|
||
if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700;
|
||
}
|
||
|
||
// Погода (биом-зависимая)
|
||
updateWeather(dt);
|
||
|
||
// player
|
||
updateWaterFlag(player);
|
||
|
||
// кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем [web:223]
|
||
if(player.headInWater){
|
||
player.o2 = Math.max(0, player.o2 - 6*dt); // замедлил в 3.7 раза
|
||
if(player.o2 === 0){
|
||
const damage = calculateDamage(4*dt);
|
||
player.hp -= damage;
|
||
}
|
||
} else {
|
||
player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза
|
||
}
|
||
|
||
// голод убывает, но HP не отнимает (как просили)
|
||
player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза
|
||
|
||
// Игрок не может двигаться во время сна
|
||
if(player.sleeping){
|
||
player.vx = 0;
|
||
player.vy = 0;
|
||
} else {
|
||
const dir = (inp.r?1:0) - (inp.l?1:0);
|
||
const speedMult = getWeatherSpeedMultiplier() * (player.slowTimer > 0 ? 0.4 : 1.0);
|
||
if(dir) player.vx = dir * MOVE * speedMult;
|
||
else player.vx *= 0.82;
|
||
}
|
||
|
||
// Звук шагов при движении по земле
|
||
if(player.grounded && !player.inWater && Math.abs(player.vx) > 50){
|
||
const stepInterval = 0.35; // Интервал между шагами в секундах
|
||
if(now/1000 - player.lastStepTime > stepInterval){
|
||
playSound('step');
|
||
player.lastStepTime = now/1000;
|
||
}
|
||
}
|
||
|
||
// прыжок/плавание (новая логика)
|
||
if(player.inBoat){
|
||
// Игрок в лодке - лодка следует за игроком
|
||
const dir = (inp.r?1:0) - (inp.l?1:0);
|
||
if(dir) boat.vx = dir * MOVE;
|
||
else boat.vx *= 0.95;
|
||
|
||
// Лодка плавает на воде
|
||
boat.vy = 0;
|
||
|
||
// Игрок следует за лодкой (сидит внутри неё)
|
||
player.x = boat.x + 2; // Игрок по центру лодки
|
||
player.y = boat.y - 4; // Игрок выше лодки (сидит внутри)
|
||
player.vx = boat.vx;
|
||
player.vy = boat.vy;
|
||
player.grounded = true;
|
||
player.inWater = false; // Игрок не в воде когда в лодке
|
||
|
||
// Прыжок из лодки (высадка)
|
||
if(jumpPressed){
|
||
// Возвращаем лодку в инвентарь
|
||
inv.boat = (inv.boat || 0) + 1;
|
||
|
||
player.inBoat = false;
|
||
boat.active = false;
|
||
player.y += TILE; // Прыгаем из лодки
|
||
player.vy = -JUMP * 0.5;
|
||
playSound('splash');
|
||
}
|
||
|
||
} else if(player.inWater){
|
||
// сопротивление в воде
|
||
player.vx *= 0.90;
|
||
player.vy *= 0.92;
|
||
|
||
// Если не нажимаем прыжок - тонем (гравитация в воде)
|
||
if(!jumpPressed && !inp.j){
|
||
// Применяем гравитацию в воде - игрок тонет
|
||
player.vy += GRAV_WATER * dt;
|
||
} else {
|
||
// Если нажимаем прыжок - поднимаемся на поверхность
|
||
if(jumpPressed){
|
||
player.vy = Math.min(player.vy, -520); // рывок вверх
|
||
} else if(inp.j){
|
||
// если держим — мягкое всплытие
|
||
player.vy = Math.min(player.vy, -260);
|
||
}
|
||
}
|
||
|
||
} else {
|
||
// обычный прыжок (только по нажатию)
|
||
if(jumpPressed && player.grounded && !player.sleeping){
|
||
player.vy = -JUMP;
|
||
player.grounded = false;
|
||
player.fallStartY = player.y;
|
||
}
|
||
}
|
||
|
||
// Гравитация применяется только вне воды и вне лодки
|
||
if(!player.inWater && !player.inBoat){
|
||
player.vy += GRAV*dt;
|
||
}
|
||
|
||
// Обновляем позицию лодки
|
||
if(boat.active){
|
||
boat.x += boat.vx * dt;
|
||
boat.y += boat.vy * dt;
|
||
|
||
// Лодка не выходит за пределы воды
|
||
const boatGX = Math.floor(boat.x / TILE);
|
||
const boatGY = Math.floor(boat.y / TILE);
|
||
const below = getBlock(boatGX, boatGY + 1);
|
||
|
||
if(!below || below.t !== 'water'){
|
||
// Если лодка вышла из воды - выкидываем игрока
|
||
inv.boat = (inv.boat || 0) + 1;
|
||
player.inBoat = false;
|
||
boat.active = false;
|
||
player.y += TILE;
|
||
player.vy = -200;
|
||
playSound('splash');
|
||
}
|
||
}
|
||
|
||
// Проверяем, не доплыл ли игрок из лодки
|
||
if(player.inBoat && !boat.active){
|
||
inv.boat = (inv.boat || 0) + 1;
|
||
player.inBoat = false;
|
||
player.y += TILE;
|
||
player.vy = -200;
|
||
playSound('splash');
|
||
}
|
||
|
||
// Sub-stepped physics: применяем движение мелкими шагами
|
||
for (let step = 0; step < steps; step++) {
|
||
player.y += player.vy*dt;
|
||
resolveY(player);
|
||
player.x += player.vx*dt;
|
||
resolveX(player);
|
||
}
|
||
|
||
// Отправляем позицию на сервер (мультиплеер)
|
||
sendPlayerPosition();
|
||
|
||
// Обновляем физику воды
|
||
updateWaterPhysics(dt);
|
||
|
||
// Погода и дождь
|
||
updateWeather(dt);
|
||
updateRain(dt);
|
||
|
||
player.invuln = Math.max(0, player.invuln - dt);
|
||
if(player.slowTimer > 0) player.slowTimer = Math.max(0, player.slowTimer - dt);
|
||
|
||
// Рост культур
|
||
for(const key of Object.keys(growthTimers)){
|
||
const tile = growthTimers[key];
|
||
if(tile.stage < 3){
|
||
tile.growTimer -= dt;
|
||
if(tile.growTimer <= 0){
|
||
tile.stage++;
|
||
tile.growTimer = 8 + Math.random()*6;
|
||
// Обновляем визуальный блок
|
||
const [gxStr, gyStr] = key.split(',');
|
||
const gx = parseInt(gxStr), gy = parseInt(gyStr);
|
||
const curBlock = getBlock(gx, gy);
|
||
if(curBlock && curBlock.t !== curBlock.t.replace(/_stage\d/, '_stage'+tile.stage)){
|
||
// Вычисляем следующую стадию
|
||
const baseType = curBlock.t.replace(/_stage\d/, '');
|
||
const nextType = baseType + '_stage' + tile.stage;
|
||
setBlock(gx, gy, nextType);
|
||
sendBlockChange(gx, gy, nextType, 'add');
|
||
// Проверяем созрел ли
|
||
if(tile.stage >= 3) delete growthTimers[key];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Voice position update
|
||
voicePosT += dt;
|
||
if(voicePosT > 0.5 && voiceSocket && voiceSocket.connected){
|
||
voicePosT = 0;
|
||
voiceSocket.emit('voice_pos', { x: player.x, y: player.y });
|
||
}
|
||
|
||
// Furnace tick
|
||
tickFurnaces(dt);
|
||
|
||
// Обновляем UI печи если открыта
|
||
if(currentFurnaceKey && Math.random() < 0.1){
|
||
renderFurnaceUI();
|
||
}
|
||
|
||
// Projectile tick (стрелы)
|
||
for(let i = projectiles.length-1; i>=0; i--){
|
||
const p = projectiles[i];
|
||
p.x += p.vx * dt;
|
||
p.y += p.vy * dt;
|
||
p.vy += 400 * dt; // гравитация
|
||
p.life -= dt;
|
||
|
||
// Столкновение с блоком
|
||
const gx = Math.floor(p.x / TILE);
|
||
const gy = Math.floor(p.y / TILE);
|
||
const blk = getBlock(gx, gy);
|
||
if(blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid){
|
||
// Врезался в стену
|
||
if(p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50%
|
||
projectiles.splice(i, 1);
|
||
continue;
|
||
}
|
||
|
||
// Столкновение с сущностью
|
||
if(p.owner === 'mob'){
|
||
// Попал в игрока
|
||
if(p.x > player.x && p.x < player.x+player.w && p.y > player.y && p.y < player.y+player.h){
|
||
if(player.invuln <= 0){
|
||
player.hp -= calculateDamage(p.dmg);
|
||
player.invuln = 0.4;
|
||
player.vx += p.vx * 0.3;
|
||
player.vy -= 150;
|
||
playSound('hit1');
|
||
}
|
||
projectiles.splice(i, 1);
|
||
continue;
|
||
}
|
||
} else {
|
||
// Попал в моба — check all mobs (client-authoritative)
|
||
const allArrowMobs = getAllMobs();
|
||
for(let j = allArrowMobs.length - 1; j >= 0; j--){
|
||
const m = allArrowMobs[j];
|
||
if(m.dead) continue;
|
||
if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){
|
||
m.hp -= p.dmg;
|
||
m.vx += p.vx * 0.2;
|
||
m.vy -= 200;
|
||
// Server-spawned mob: emit arrow hit to server for relay
|
||
if(m.id !== undefined && isMultiplayer){
|
||
socket.emit('mob_arrow_hit', { id: m.id, dmg: p.dmg, vx: p.vx });
|
||
if(m.hp <= 0){
|
||
socket.emit('mob_died', { id: m.id });
|
||
}
|
||
}
|
||
if(m.hp <= 0){
|
||
spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind);
|
||
grantXP(getMobXP(m.kind));
|
||
// Remove from the correct array
|
||
if(m.id !== undefined){
|
||
serverMobs.delete(m.id);
|
||
} else {
|
||
const localIdx = mobs.indexOf(m);
|
||
if(localIdx >= 0) mobs.splice(localIdx, 1);
|
||
}
|
||
rebuildHotbar();
|
||
}
|
||
projectiles.splice(i, 1);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Таймаут
|
||
if(p.life <= 0) projectiles.splice(i, 1);
|
||
}
|
||
|
||
// TNT tick
|
||
for(const key of Array.from(activeTNT)){
|
||
const b = grid.get(key);
|
||
if(!b || b.dead){ activeTNT.delete(key); continue; }
|
||
b.fuse -= dt;
|
||
if(b.fuse <= 0){
|
||
explodeAt(b.gx,b.gy);
|
||
}
|
||
}
|
||
|
||
// mobs spawn (с обеих сторон камеры) — только в одиночном режиме (MP mobs come from server events)
|
||
spawnT += dt;
|
||
if(!isMultiplayer && spawnT > 1.8 && getAllMobs().length < 30){
|
||
spawnT = 0;
|
||
|
||
// Выбираем сторону спавна (левая или правая)
|
||
const spawnLeft = Math.random() < 0.5;
|
||
const gx = spawnLeft
|
||
? Math.floor((camX - 200)/TILE)
|
||
: Math.floor((camX + W + 200)/TILE);
|
||
|
||
genColumn(gx);
|
||
const sgy = surfaceGyAt(gx);
|
||
const wx = gx*TILE + 4;
|
||
const wy = (sgy-2)*TILE;
|
||
|
||
// не спавнить в воде
|
||
const top = getBlock(gx, sgy);
|
||
if(top && top.t==='water') {
|
||
// skip
|
||
} else {
|
||
const night = isNight();
|
||
if(night){
|
||
// Ночью спавним враждебных мобов (максимум 12 хостайл)
|
||
const hostileCount = mobs.filter(m => m.kind==='zombie'||m.kind==='creeper'||m.kind==='skeleton').length;
|
||
if(hostileCount < 12){
|
||
const rand = Math.random();
|
||
if(rand < 0.35){
|
||
mobs.push(new Zombie(wx, wy));
|
||
} else if(rand < 0.55){
|
||
mobs.push(new Creeper(wx, wy));
|
||
} else {
|
||
mobs.push(new Skeleton(wx, wy));
|
||
}
|
||
}
|
||
}
|
||
// Животные спавнятся и днём и ночью (с лимитом)
|
||
const animalCount = mobs.filter(m => m.kind==='pig'||m.kind==='chicken').length;
|
||
if(animalCount < 8){
|
||
mobs.push(Math.random()<0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
|
||
}
|
||
}
|
||
}
|
||
|
||
// mobs update — mobAI runs on ALL mobs (client-authoritative for server-spawned mobs too)
|
||
{
|
||
// Local mobs
|
||
for(let i=mobs.length-1;i>=0;i--){
|
||
const m = mobs[i];
|
||
mobAI(m, dt);
|
||
if(m.hp<=0) mobs.splice(i,1);
|
||
}
|
||
// Server-spawned mobs (MP client-authoritative)
|
||
if(isMultiplayer){
|
||
for (const [id, sm] of serverMobs) {
|
||
mobAI(sm, dt);
|
||
if(sm.hp <= 0){
|
||
// Schedule removal (don't delete during iteration)
|
||
sm.dead = true;
|
||
}
|
||
}
|
||
// Remove dead server mobs
|
||
for (const [id, sm] of serverMobs) {
|
||
if(sm.dead) serverMobs.delete(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// particles
|
||
for(let i=parts.length-1;i>=0;i--){
|
||
const p = parts[i];
|
||
p.t -= dt;
|
||
p.x += p.vx*dt;
|
||
p.y += p.vy*dt;
|
||
p.vy += GRAV*dt;
|
||
if(p.t <= 0) parts.splice(i,1);
|
||
}
|
||
|
||
// death
|
||
if(player.hp <= 0){
|
||
deathEl.style.display='flex';
|
||
} else if(deathEl.style.display === 'flex') {
|
||
// Если HP > 0 но экран смерти всё ещё показан - скрываем его
|
||
deathEl.style.display='none';
|
||
}
|
||
|
||
// render
|
||
const night = isNight();
|
||
|
||
// sky
|
||
ctx.fillStyle = night ? '#070816' : (isRaining ? '#6B7B8D' : '#87CEEB');
|
||
ctx.fillRect(0,0,W,H);
|
||
|
||
// clouds (parallax x/y)
|
||
ctx.save();
|
||
ctx.translate(-camX*0.5, -camY*0.15);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.65)';
|
||
for(const c of clouds){
|
||
ctx.fillRect(c.x, c.y, c.w, 26);
|
||
ctx.fillRect(c.x+20, c.y-10, c.w*0.6, 22);
|
||
}
|
||
ctx.restore();
|
||
|
||
// world
|
||
ctx.save();
|
||
ctx.translate(-camX, -camY);
|
||
|
||
const minGX = Math.floor(camX/TILE)-2;
|
||
const maxGX = Math.floor((camX+W)/TILE)+2;
|
||
const minGY = Math.floor(camY/TILE)-6;
|
||
const maxGY = Math.floor((camY+H)/TILE)+6;
|
||
|
||
// draw blocks (по массиву, но фильтруем диапазоном)
|
||
for(const b of blocks){
|
||
if(b.dead) continue;
|
||
if(b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
|
||
|
||
const def = BLOCKS[b.t];
|
||
if(def.alpha){
|
||
ctx.save();
|
||
ctx.globalAlpha = def.alpha;
|
||
ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
|
||
ctx.restore();
|
||
} else {
|
||
ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE);
|
||
}
|
||
|
||
// TNT мигает, если активирован
|
||
if(b.t==='tnt' && b.active && Math.sin(now/60)>0){
|
||
ctx.fillStyle='rgba(255,255,255,0.45)';
|
||
ctx.fillRect(b.gx*TILE, b.gy*TILE, TILE, TILE);
|
||
}
|
||
|
||
// огонь костра
|
||
if(b.t==='campfire'){
|
||
drawFire(b.gx*TILE, b.gy*TILE, now);
|
||
}
|
||
// Печь — огонь когда обжигает
|
||
if(b.t==='furnace' && activeFurnaces.has(`${b.gx},${b.gy}`)){
|
||
drawFire(b.gx*TILE + 8, b.gy*TILE + 5, now);
|
||
}
|
||
}
|
||
|
||
// mobs
|
||
const allMobsRender = getAllMobs();
|
||
for(const m of allMobsRender){
|
||
if(m.kind==='zombie'){
|
||
ctx.fillStyle = '#2ecc71';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
ctx.fillStyle = '#c0392b';
|
||
ctx.fillRect(m.x+6, m.y+12, 6,6);
|
||
ctx.fillRect(m.x+22, m.y+12, 6,6);
|
||
} else if(m.kind==='pig'){
|
||
ctx.fillStyle = '#ffb6c1';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x+22, m.y+5, 3,3);
|
||
ctx.fillStyle = '#ff69b4';
|
||
ctx.fillRect(m.x+28, m.y+12, 6,6);
|
||
} else if(m.kind==='chicken'){
|
||
// chicken
|
||
ctx.fillStyle = '#ecf0f1';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
ctx.fillStyle = '#f39c12';
|
||
ctx.fillRect(m.x+18, m.y+10, 6,4);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x+8, m.y+6, 3,3);
|
||
} else if(m.kind==='creeper'){
|
||
// creeper
|
||
ctx.fillStyle = '#4CAF50';
|
||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||
// Глаза
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x+8, m.y+8, 4,4);
|
||
ctx.fillRect(m.x+22, m.y+8, 4,4);
|
||
// Рот
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x+12, m.y+20, 10,4);
|
||
// Ноги
|
||
ctx.fillStyle = '#4CAF50';
|
||
ctx.fillRect(m.x+4, m.y+30, 6,20);
|
||
ctx.fillRect(m.x+24, m.y+30, 6,20);
|
||
} else if(m.kind==='skeleton'){
|
||
// skeleton - детализированный
|
||
// Тело
|
||
ctx.fillStyle = '#ECEFF1';
|
||
ctx.fillRect(m.x+10, m.y+20, 14, 12);
|
||
// Череп
|
||
ctx.fillRect(m.x+8, m.y+0, 18, 18);
|
||
// Глазницы
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x+10, m.y+6, 4,4);
|
||
ctx.fillRect(m.x+20, m.y+6, 4,4);
|
||
// Нос
|
||
ctx.fillRect(m.x+15, m.y+12, 4,2);
|
||
// Руки
|
||
ctx.fillStyle = '#ECEFF1';
|
||
ctx.fillRect(m.x+2, m.y+20, 6,14);
|
||
ctx.fillRect(m.x+26, m.y+20, 6,14);
|
||
// Ноги
|
||
ctx.fillRect(m.x+10, m.y+32, 6, 18);
|
||
ctx.fillRect(m.x+18, m.y+32, 6, 18);
|
||
// Лук в руке
|
||
ctx.save();
|
||
ctx.translate(m.x + 30, m.y + 22);
|
||
ctx.strokeStyle = '#8B4513';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, 8, -Math.PI*0.7, Math.PI*0.7);
|
||
ctx.stroke();
|
||
// Тетива
|
||
ctx.strokeStyle = '#ccc';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(8*Math.cos(-Math.PI*0.7), 8*Math.sin(-Math.PI*0.7));
|
||
ctx.lineTo(8*Math.cos(Math.PI*0.7), 8*Math.sin(Math.PI*0.7));
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
} else if(m.kind==='scorpion') {
|
||
// скорпион — оранжево-коричневый
|
||
ctx.fillStyle = '#d35400';
|
||
ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-8);
|
||
ctx.fillStyle = '#c0392b';
|
||
ctx.fillRect(m.x+m.w-4, m.y-6, 4, 10);
|
||
ctx.fillRect(m.x+m.w-2, m.y-10, 3, 5);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x+4, m.y+6, 3, 3);
|
||
ctx.fillRect(m.x+m.w-7, m.y+6, 3, 3);
|
||
ctx.fillStyle = '#d35400';
|
||
ctx.fillRect(m.x-4, m.y+8, 6, 4);
|
||
ctx.fillRect(m.x+m.w-2, m.y+8, 6, 4);
|
||
} else if(m.kind==='polar_bear') {
|
||
ctx.fillStyle = '#ecf0f1';
|
||
ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-4);
|
||
ctx.fillRect(m.x+8, m.y-2, m.w-16, 10);
|
||
ctx.fillStyle = '#bdc3c7';
|
||
ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 4);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x+12, m.y+1, 3, 3);
|
||
ctx.fillRect(m.x+m.w-14, m.y+1, 3, 3);
|
||
ctx.fillStyle = '#ecf0f1';
|
||
ctx.fillRect(m.x+8, m.y-4, 4, 4);
|
||
ctx.fillRect(m.x+m.w-12, m.y-4, 4, 4);
|
||
} else if(m.kind==='slime') {
|
||
const bounce = Math.sin(performance.now()/200)*3;
|
||
ctx.fillStyle = 'rgba(46,204,113,0.8)';
|
||
ctx.fillRect(m.x-1, m.y-1+bounce, m.w+2, m.h+2);
|
||
ctx.fillStyle = 'rgba(39,174,96,0.9)';
|
||
ctx.fillRect(m.x+1, m.y+1+bounce, m.w-2, m.h-2);
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillRect(m.x+4, m.y+6+bounce, 6, 6);
|
||
ctx.fillRect(m.x+m.w-10, m.y+6+bounce, 6, 6);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x+6, m.y+8+bounce, 3, 3);
|
||
ctx.fillRect(m.x+m.w-8, m.y+8+bounce, 3, 3);
|
||
} else if(m.kind==='eagle') {
|
||
ctx.fillStyle = '#8B4513';
|
||
ctx.fillRect(m.x+8, m.y+6, m.w-16, m.h-10);
|
||
const wingY = Math.sin(performance.now()/150)*4;
|
||
ctx.fillRect(m.x-6, m.y+4+wingY, 16, 6);
|
||
ctx.fillRect(m.x+m.w-10, m.y+4-wingY, 16, 6);
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 6);
|
||
ctx.fillStyle = '#f39c12';
|
||
ctx.fillRect(m.x+m.w/2-1, m.y+6, 4, 3);
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(m.x+m.w/2-1, m.y+3, 2, 2);
|
||
}
|
||
}
|
||
|
||
// boat (рисуем первой, чтобы игрок был внутри неё)
|
||
if(boat.active){
|
||
ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE);
|
||
}
|
||
|
||
// other players (multiplayer)
|
||
for(const [socketId, p] of otherPlayers){
|
||
if(heroImg.complete){
|
||
ctx.drawImage(heroImg, p.x - (TILE-player.w)/2, p.y - (TILE-player.h)/2, TILE, TILE);
|
||
} else {
|
||
ctx.fillStyle = p.color;
|
||
ctx.fillRect(p.x, p.y, 34, 34);
|
||
}
|
||
// Имя игрока (мелко над персонажем)
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = '12px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(p.name, p.x + 17, p.y - 8);
|
||
}
|
||
|
||
// player
|
||
if(heroImg.complete){
|
||
ctx.drawImage(heroImg, player.x - (TILE-player.w)/2, player.y - (TILE-player.h)/2, TILE, TILE);
|
||
} else {
|
||
ctx.fillStyle='#fff';
|
||
ctx.fillRect(player.x, player.y, player.w, player.h);
|
||
}
|
||
|
||
// projectiles (стрелы)
|
||
for(const p of projectiles){
|
||
const angle = Math.atan2(p.vy, p.vx);
|
||
ctx.save();
|
||
ctx.translate(p.x, p.y);
|
||
ctx.rotate(angle);
|
||
ctx.fillStyle = p.owner === 'mob' ? '#c0392b' : '#f1c40f';
|
||
ctx.fillRect(-12, -1.5, 24, 3);
|
||
// наконечник
|
||
ctx.beginPath();
|
||
ctx.moveTo(12, -4);
|
||
ctx.lineTo(16, 0);
|
||
ctx.lineTo(12, 4);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
// оперение
|
||
ctx.fillStyle = '#888';
|
||
ctx.fillRect(-12, -3, 4, 2);
|
||
ctx.fillRect(-12, 1, 4, 2);
|
||
ctx.restore();
|
||
}
|
||
|
||
// particles
|
||
for(const p of parts){
|
||
ctx.fillStyle = p.c;
|
||
ctx.fillRect(p.x-2, p.y-2, 4, 4);
|
||
}
|
||
|
||
// Стрелы скелета
|
||
for(const m of mobs){
|
||
if(m.kind==='skeleton' && m.shootCooldown > 0.5){
|
||
// Рисуем стрелу
|
||
const arrowX = m.x + m.w/2;
|
||
const arrowY = m.y + 15;
|
||
const targetX = player.x + player.w/2;
|
||
const targetY = player.y + player.h/2;
|
||
const angle = Math.atan2(targetY - arrowY, targetX - arrowX);
|
||
const speed = 400;
|
||
|
||
// Проверяем, попала ли стрела
|
||
const dx = targetX - arrowX;
|
||
const dy = targetY - arrowY;
|
||
const dist = Math.hypot(dx, dy);
|
||
|
||
// Рисуем стрелу
|
||
ctx.save();
|
||
ctx.translate(arrowX, arrowY);
|
||
ctx.rotate(angle);
|
||
ctx.fillStyle = '#ECEFF1';
|
||
ctx.fillRect(0, -1, 16, 2);
|
||
ctx.restore();
|
||
|
||
// Урон игроку если попали
|
||
if(dist < 150 && player.invuln <= 0){
|
||
player.hp -= 8;
|
||
player.invuln = 0.5;
|
||
player.vx += Math.cos(angle) * 300;
|
||
player.vy -= 200;
|
||
playSound('hit1');
|
||
}
|
||
}
|
||
}
|
||
|
||
// build ghost
|
||
if(mode()==='build' && mouse.x!==null && !craftOpen && player.hp>0){
|
||
const wx = mouse.x + camX;
|
||
const wy = mouse.y + camY;
|
||
const gx = Math.floor(wx/TILE);
|
||
const gy = Math.floor(wy/TILE);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(gx*TILE, gy*TILE, TILE, TILE);
|
||
}
|
||
|
||
ctx.restore();
|
||
|
||
// lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker)
|
||
if(night){
|
||
// 1) Рисуем тёмный оверлей на offscreen canvas
|
||
lightC.width = W*dpr;
|
||
lightC.height = H*dpr;
|
||
lightCtx.setTransform(dpr,0,0,dpr,0,0);
|
||
lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
|
||
lightCtx.fillRect(0,0,W,H);
|
||
|
||
// 2) Вырезаем «окна света» через destination-out — свет не проходит сквозь стены
|
||
lightCtx.globalCompositeOperation = 'destination-out';
|
||
|
||
// Функция: рисуем мягкий луч света с затуханием за стенами
|
||
function castLight(sx, sy, radius) {
|
||
const flick = 0.92 + Math.sin(now/80 + sx*0.01)*0.04 + Math.sin(now/130 + sy*0.02)*0.04;
|
||
const r = radius * flick;
|
||
// 24 луча — мягкий круглый свет
|
||
const steps = 24;
|
||
// Собираем дистанции до стен по лучам
|
||
const dists = new Float32Array(steps);
|
||
for(let i=0; i<steps; i++){
|
||
const angle = (i/steps) * Math.PI * 2;
|
||
const dx = Math.cos(angle);
|
||
const dy = Math.sin(angle);
|
||
let maxDist = r;
|
||
// Идём по лучу пока не упрёмся в стену
|
||
for(let step=TILE*0.3; step<r; step+=TILE*0.35){
|
||
const gx = Math.floor((sx + dx*step)/TILE);
|
||
const gy = Math.floor((sy + dy*step)/TILE);
|
||
const blk = getBlock(gx, gy);
|
||
if(blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE*0.3){
|
||
maxDist = step;
|
||
break;
|
||
}
|
||
}
|
||
dists[i] = maxDist;
|
||
}
|
||
// Рисуем сглаженный полигон по dists
|
||
const cx = sx-camX, cy = sy-camY;
|
||
// Центр: яркая точка
|
||
const maxR = Math.max(...dists);
|
||
const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
|
||
grad.addColorStop(0, 'rgba(255,255,255,1)');
|
||
grad.addColorStop(0.4, 'rgba(255,255,255,0.8)');
|
||
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||
lightCtx.fillStyle = grad;
|
||
// Рисуем shape по dists (звездоподобный полигон)
|
||
lightCtx.beginPath();
|
||
for(let i=0; i<=steps; i++){
|
||
const idx = i % steps;
|
||
const nextIdx = (i+1) % steps;
|
||
const avgD = (dists[idx] + dists[nextIdx]) / 2;
|
||
const angle = (idx/steps) * Math.PI * 2;
|
||
const px = cx + Math.cos(angle) * dists[idx];
|
||
const py = cy + Math.sin(angle) * dists[idx];
|
||
if(i===0) lightCtx.moveTo(px, py);
|
||
else lightCtx.lineTo(px, py);
|
||
}
|
||
lightCtx.closePath();
|
||
lightCtx.fill();
|
||
}
|
||
|
||
// Источники света
|
||
for(const b of blocks){
|
||
if(b.dead) continue;
|
||
if(b.gx < minGX-5 || b.gx > maxGX+5 || b.gy < minGY-5 || b.gy > maxGY+5) continue;
|
||
const def = BLOCKS[b.t];
|
||
if(def.lightRadius){
|
||
castLight(b.gx*TILE + TILE/2, b.gy*TILE + TILE/2, def.lightRadius);
|
||
}
|
||
}
|
||
|
||
// 3) Накладываем lightmap на основной canvas
|
||
lightCtx.globalCompositeOperation = 'source-over';
|
||
ctx.drawImage(lightC, 0, 0, W, H);
|
||
|
||
// 4) Тёплый оверлей от источников света (additive, мягкий)
|
||
ctx.save();
|
||
ctx.globalCompositeOperation = 'lighter';
|
||
for(const b of blocks){
|
||
if(b.dead) continue;
|
||
if(b.gx < minGX-3 || b.gx > maxGX+3 || b.gy < minGY-3 || b.gy > maxGY+3) continue;
|
||
const def = BLOCKS[b.t];
|
||
if(def.lightRadius){
|
||
const flick = 0.7 + Math.sin(now/90 + b.gx*3.7)*0.15 + Math.sin(now/140 + b.gy*2.3)*0.15;
|
||
const wx = b.gx*TILE + TILE/2 - camX;
|
||
const wy = b.gy*TILE + TILE/2 - camY;
|
||
const r = def.lightRadius * 0.75 * flick;
|
||
const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r);
|
||
grad.addColorStop(0, `rgba(255,180,80,${0.22*flick})`);
|
||
grad.addColorStop(0.5, `rgba(255,140,40,${0.10*flick})`);
|
||
grad.addColorStop(1, 'rgba(255,100,20,0)');
|
||
ctx.fillStyle = grad;
|
||
ctx.beginPath();
|
||
ctx.arc(wx, wy, r, 0, Math.PI*2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// Дождь (после ночного оверлея)
|
||
drawRain();
|
||
if(Math.random()<0.25){
|
||
hpEl.textContent = Math.max(0, Math.ceil(player.hp));
|
||
foodEl.textContent = Math.ceil(player.hunger);
|
||
document.getElementById('o2').textContent = Math.ceil(player.o2);
|
||
sxEl.textContent = Math.floor(player.x/TILE);
|
||
syEl.textContent = Math.floor(player.y/TILE);
|
||
todEl.textContent = night ? 'Ночь' : 'День';
|
||
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);
|
||
}
|
||
|
||
// Рисуем дропы
|
||
drawDrops(ctx);
|
||
// Пикап дропов
|
||
pickupDrops();
|
||
// Popup уровня
|
||
drawLevelUpPopup(ctx);
|
||
|
||
// Миникарта (обновляем раз в ~4 кадра для оптимизации)
|
||
if(minimapOpen && Math.random() < 0.25){
|
||
renderMinimap();
|
||
}
|
||
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
requestAnimationFrame(loop);
|
||
})();
|