grechka-game/game.js_new

5191 lines
182 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

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

// GrechkaCraft — auto-bundled from ES modules
(function() {
"use strict";
// === core/constants.js ===
// ==================== КОНСТАНТЫ ИГРЫ ====================
const TILE = 40;
// Мир
const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
// Физика
const GRAV = 2200;
const GRAV_WATER = 550;
const MOVE = 320;
const JUMP = 760;
// День/ночь
const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза
// Сохранение
const SAVE_KEY = 'minegrechka_save';
// Сеть
const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
// Погода
const MAX_RAINDROPS = 200;
// Чат
const MAX_CHAT_MESSAGES = 50;
// Голосовой чат
const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru';
// === core/state.js ===
// Все мутабельные переменные игры в одном объекте состояния
const state = {
// Камера
camX: 0,
camY: 0,
// День/ночь
worldTime: 0,
isNightTime: false,
// Мультиплеер
isMultiplayer: false,
mySocketId: null,
socket: null,
// Инвентарь/UI
selected: 0,
showFullInventory: false,
craftOpen: false,
inventoryOpen: false,
chatOpen: false,
modeIdx: 0,
// Мир
worldSeed: Math.floor(Math.random() * 1000000),
// Погода
isRaining: false,
rainIntensity: 0,
weatherTimer: 0,
weatherChangeInterval: 60 + Math.random() * 120,
// Мобы/спавн
spawnT: 0,
// Цикл
last: 0,
prevJump: false,
// Сеть — throttle отправки позиции
lastMoveSendTime: 0,
lastSentX: 0,
lastSentY: 0,
// Игрок
player: {
x: 6 * TILE,
y: 0 * TILE,
w: 34,
h: 34,
vx: 0,
vy: 0,
grounded: false,
inWater: false,
headInWater: false,
hp: 100,
hunger: 100,
o2: 100,
invuln: 0,
fallStartY: 0,
lastStepTime: 0,
sleeping: false,
inBoat: false,
armor: 0,
equippedArmor: null
},
// Точка спавна
spawnPoint: { x: 6 * TILE, y: 0 * TILE },
// Инвентарь
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,
bow: 0, furnace: 0,
bed: 0, boat: 0,
iron_ingot: 0, gold_ingot: 0, copper_ingot: 0
},
// Лодка
boat: {
x: 0, y: 0,
w: 34, h: 34,
vx: 0, vy: 0,
active: false,
inWater: false
},
// Ввод
inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false },
// Мышь
mouse: { x: 0, y: 0 },
// Другие игроки (MP)
otherPlayers: new Map(),
// Серверные мобы (MP)
serverMobs: new Map(),
// Мобы
mobs: [],
// Снаряды
projectiles: [],
// Отслеживание изменений мира
placedBlocks: [],
removedBlocks: [],
// Серверные изменения
serverOverrides: new Map(),
// Чат
chatMessages: [],
// Погода — капли
raindrops: [],
// Облака
clouds: Array.from({ length: 10 }, () => ({
x: Math.random() * 2000,
y: -200 - Math.random() * 260,
w: 80 + Math.random() * 120,
s: 12 + Math.random() * 20
})),
// Частицы
parts: [],
// Активный TNT
activeTNT: new Set(),
// Прочность инструментов
toolDurability: new Map(),
// Последние выбранные предметы
recentItems: [],
// Активные печи
activeFurnaces: new Map(),
// Сгенерированные колонны
generated: new Set(),
// Изображение героя
heroImg: null
};
// === config.js ===
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
// Возможность переопределить сервер через query string
const urlParams = new URLSearchParams(window.location.search);
const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
// Защита от 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}`);
// Setters для изменения worldId/playerName из других модулей
function setWorldId(id) { worldId = id; }
function setPlayerName(name) { playerName = name; }
// === ui/start-menu.js ===
// ==================== START MENU ====================
// Detect Telegram WebApp
const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData);
let tgUser = null;
let tgSaveLoaded = false;
if (isTgWebApp) {
try {
Telegram.WebApp.ready();
Telegram.WebApp.expand();
tgUser = Telegram.WebApp.initDataUnsafe?.user || null;
console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username);
// Set player name from TG
if (tgUser && tgUser.first_name) {
const tgName = tgUser.username || tgUser.first_name;
localStorage.setItem('minegrechka_playerName', tgName);
}
} catch (e) {
console.warn('[TG] WebApp init error:', e);
}
}
// Export TG info for other modules
// ==================== HELPERS ====================
function generateShortWorldCode() {
// Generate readable 6-char code: consonants + digits (easy to copy/share)
const c = 'bcdfghjkmnpqrstvwxyz';
const d = '23456789';
let code = '';
for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)];
for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)];
return code;
}
function showToast(msg, duration) {
duration = duration || 1800;
const t = document.createElement('div');
t.textContent = msg;
t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;';
document.body.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration);
}
function customAlert(msg) {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100000;';
const box = document.createElement('div');
box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:24px;max-width:360px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;';
const text = document.createElement('div');
text.textContent = msg;
text.style.marginBottom = '16px';
text.style.fontWeight = 'bold';
text.style.fontSize = '16px';
const btn = document.createElement('button');
btn.textContent = 'OK';
btn.style.cssText = 'padding:8px 24px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;';
btn.onclick = () => overlay.remove();
box.appendChild(text);
box.appendChild(btn);
overlay.appendChild(box);
document.body.appendChild(overlay);
}
// ==================== MAIN START MENU ====================
// Minecraft-style CSS for the start menu
const mcCSS = `
.mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;
display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace;
image-rendering:pixelated; }
.mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; }
.mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%;
background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; }
.mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000;
padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000; }
.mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center;
border-bottom:4px solid #000; }
.mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000;
letter-spacing:2px; }
.mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; }
.mc-body { padding:16px;background:#C6C6C6; }
.mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px;
font-family:'Courier New',monospace;font-size:16px;font-weight:bold;
cursor:pointer;text-align:center;border:3px solid #000;
image-rendering:pixelated;box-shadow:3px 3px 0 #000;
transition:transform 0.05s,box-shadow 0.05s; }
.mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; }
.mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; }
.mc-btn-green:hover { background:#6DAF4E; }
.mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; }
.mc-btn-blue:hover { background:#4B73BE; }
.mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; }
.mc-btn-purple:hover { background:#9555BE; }
.mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; }
.mc-btn-orange:hover { background:#DA8500; }
.mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; }
.mc-btn-red:hover { background:#BE3B3B; }
.mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; }
.mc-btn-gray:hover { background:#666;color:#DDD; }
.mc-btn-outline { background:transparent;color:#555;border-color:#555;
text-shadow:none;box-shadow:2px 2px 0 #333; }
.mc-btn-outline:hover { background:#DDD;color:#222; }
.mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace;
font-size:16px;background:#FFF;border:3px solid #000;color:#222;
box-shadow:inset 2px 2px 0 #999;margin:4px 0;box-sizing:border-box; }
.mc-input:focus { outline:none;border-color:#FFAA00; }
.mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; }
.mc-divider { border:none;border-top:2px solid #999;margin:12px 0; }
.mc-friend-row { display:flex;justify-content:space-between;align-items:center;
padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999;
box-shadow:2px 2px 0 #999; }
.mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace;
font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000;
box-shadow:1px 1px 0 #000; }
.mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; }
.mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; }
.mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; }
.mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; }
.mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; }
`;
function showStartMenu() {
return new Promise(async (resolve) => {
const urlParams = new URLSearchParams(window.location.search);
const worldParam = urlParams.get('world');
const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param)
? Telegram.WebApp.initDataUnsafe.start_param.trim()
: null;
// If world is in URL or TG start param — skip menu, auto-join
if ((worldParam && worldParam.trim() !== '') || tgStartParam) {
const worldId = (worldParam && worldParam.trim()) || tgStartParam;
console.log('[Menu] Joining world from URL/startParam:', worldId);
// Set player name from TG or localStorage or default
if (!state.playerName) {
if (isTgWebApp && tgUser) {
state.playerName = tgUser.username || tgUser.first_name || 'Игрок';
} else {
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
}
localStorage.setItem('minegrechka_playerName', state.playerName);
}
resolve(worldId);
return;
}
// Inject CSS
const styleEl = document.createElement('style');
styleEl.textContent = mcCSS;
document.head.appendChild(styleEl);
// Build overlay
const overlay = document.createElement('div');
overlay.className = 'mc-overlay';
overlay.id = 'startMenu';
overlay.innerHTML = '<div class="mc-bg"></div><div class="mc-dirt"></div>';
const box = document.createElement('div');
box.className = 'mc-box';
// Title bar
const titleBar = document.createElement('div');
titleBar.className = 'mc-titlebar';
titleBar.innerHTML = '<div class="mc-title">⛏️ GrechkaCraft</div>' +
'<div class="mc-subtitle">' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '</div>';
box.appendChild(titleBar);
const body = document.createElement('div');
body.className = 'mc-body';
// Mutable worldId holder
let selectedWorldId = null;
// === Buttons ===
// 🌍 New World
const btnNew = document.createElement('button');
btnNew.className = 'mc-btn mc-btn-green';
btnNew.textContent = '🌍 Новый мир';
btnNew.onclick = () => {
selectedWorldId = generateShortWorldCode();
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
};
body.appendChild(btnNew);
// 🔗 Join by code
const btnJoin = document.createElement('button');
btnJoin.className = 'mc-btn mc-btn-blue';
btnJoin.textContent = '🔗 Вступить по коду';
btnJoin.onclick = () => {
body.innerHTML = '';
const lbl = document.createElement('div');
lbl.className = 'mc-label';
lbl.textContent = 'Код мира:';
body.appendChild(lbl);
const codeInput = document.createElement('input');
codeInput.className = 'mc-input';
codeInput.type = 'text';
codeInput.placeholder = 'bkh237';
codeInput.maxLength = 12;
body.appendChild(codeInput);
const btnGo = document.createElement('button');
btnGo.className = 'mc-btn mc-btn-blue';
btnGo.textContent = 'Войти →';
btnGo.onclick = () => {
const code = codeInput.value.trim().toLowerCase();
if (code.length >= 3) {
selectedWorldId = code;
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
}
};
body.appendChild(btnGo);
const btnBack = document.createElement('button');
btnBack.className = 'mc-btn mc-btn-gray';
btnBack.textContent = '← Назад';
btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
body.appendChild(btnBack);
codeInput.focus();
codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); });
};
body.appendChild(btnJoin);
// 💾 Continue saved game
const hasLocalSave = !!localStorage.getItem(state.SAVE_KEY || 'minegrechka_save');
if (isTgWebApp || hasLocalSave) {
const btnHistory = document.createElement('button');
btnHistory.className = 'mc-btn mc-btn-purple';
btnHistory.textContent = '💾 Продолжить игру';
btnHistory.onclick = async () => {
if (isTgWebApp && tgUser) {
try {
const resp = await fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
const data = await resp.json();
if (data.ok && data.world_id) {
selectedWorldId = data.world_id;
tgSaveLoaded = true;
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
return;
}
} catch (e) { console.warn('[TG] Load save failed:', e); }
}
if (hasLocalSave) {
// Continue with a new world code (the save will be loaded separately by initDB/loadGame)
selectedWorldId = generateShortWorldCode();
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
return;
}
customAlert('Сохранений не найдено');
};
body.appendChild(btnHistory);
}
// === TG-only buttons ===
if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) {
// 📤 Invite friends
const btnInvite = document.createElement('button');
btnInvite.className = 'mc-btn mc-btn-outline';
btnInvite.style.marginTop = '16px';
btnInvite.textContent = '📤 Пригласить друзей';
btnInvite.onclick = () => {
const code = selectedWorldId || generateShortWorldCode();
const url = 'https://t.me/' + state.TELEGRAM_BOT_USERNAME + '/app?startapp=' + encodeURIComponent(code);
if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) {
Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code));
} else {
navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url));
}
};
body.appendChild(btnInvite);
// 👥 Friends
const btnFriends = document.createElement('button');
btnFriends.className = 'mc-btn mc-btn-orange';
btnFriends.style.marginTop = '8px';
btnFriends.textContent = '👥 Друзья';
btnFriends.onclick = async () => {
try {
const resp = await fetch(state.SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id);
const data = await resp.json();
body.innerHTML = '';
// Back button at top
const btnBackTop = document.createElement('button');
btnBackTop.className = 'mc-btn mc-btn-gray';
btnBackTop.textContent = '← Назад';
btnBackTop.style.marginBottom = '12px';
btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
body.appendChild(btnBackTop);
// Nickname section
if (data.nickname) {
const nickDiv = document.createElement('div');
nickDiv.className = 'mc-friend-row';
nickDiv.innerHTML = '🔗 Твой ник: <b>' + data.nickname + '</b>';
nickDiv.style.background = '#D5E8C5';
body.appendChild(nickDiv);
} else {
const nickLabel = document.createElement('div');
nickLabel.className = 'mc-label';
nickLabel.style.color = '#9E2B2B';
nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:';
body.appendChild(nickLabel);
const nickRow = document.createElement('div');
nickRow.style.display = 'flex';
nickRow.style.gap = '4px';
nickRow.style.marginBottom = '8px';
const nickInput = document.createElement('input');
nickInput.className = 'mc-input';
nickInput.style.flex = '1';
nickInput.type = 'text';
nickInput.maxLength = 16;
nickInput.placeholder = 'Твой ник';
const nickBtn = document.createElement('button');
nickBtn.className = 'mc-btn mc-btn-green';
nickBtn.style.width = 'auto';
nickBtn.style.marginBottom = '0';
nickBtn.style.padding = '8px 16px';
nickBtn.textContent = '✓';
nickBtn.onclick = async () => {
const n = nickInput.value.trim().toLowerCase();
if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; }
const check = await fetch(state.SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n));
const cd = await check.json();
if (!cd.available) { showToast('❌ Ник занят'); return; }
const setResp = await fetch(state.SERVER_URL + '/api/nick/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tg_id: tgUser.id, nickname: n })
});
const sd = await setResp.json();
if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
};
nickRow.appendChild(nickInput);
nickRow.appendChild(nickBtn);
body.appendChild(nickRow);
}
// Friends list
if (data.friends && data.friends.length) {
const listLabel = document.createElement('div');
listLabel.className = 'mc-label';
listLabel.textContent = '👥 Друзья:';
body.appendChild(listLabel);
for (const f of data.friends) {
const fDiv = document.createElement('div');
fDiv.className = 'mc-friend-row';
const statusDot = f.online ? '🟢' : '⚫';
let info = statusDot + ' <b>' + f.nickname + '</b>';
if (f.online && f.world_id) info += ' — мир ' + f.world_id;
const fInfo = document.createElement('div');
fInfo.innerHTML = info;
fDiv.appendChild(fInfo);
if (f.online && f.world_id) {
const joinBtn = document.createElement('button');
joinBtn.className = 'mc-friend-btn mc-friend-btn-green';
joinBtn.textContent = '🎮 Вступить';
joinBtn.onclick = () => {
selectedWorldId = f.world_id;
overlay.remove();
styleEl.remove();
resolve(selectedWorldId);
};
fDiv.appendChild(joinBtn);
}
body.appendChild(fDiv);
}
} else if (data.friends && !data.friends.length) {
const noFriends = document.createElement('div');
noFriends.className = 'mc-label';
noFriends.style.color = '#888';
noFriends.textContent = 'Пока нет друзей';
body.appendChild(noFriends);
}
// Pending friend requests
if (data.pending && data.pending.length) {
const pLabel = document.createElement('div');
pLabel.className = 'mc-label';
pLabel.style.color = '#B86E00';
pLabel.textContent = '📨 Запросы в друзья:';
body.appendChild(pLabel);
for (const p of data.pending) {
const pItem = document.createElement('div');
pItem.className = 'mc-friend-row';
const span = document.createElement('span');
span.textContent = p.from;
pItem.appendChild(span);
const btns = document.createElement('div');
btns.style.display = 'flex';
btns.style.gap = '4px';
const acceptBtn = document.createElement('button');
acceptBtn.className = 'mc-friend-btn mc-friend-btn-green';
acceptBtn.textContent = '✓';
acceptBtn.onclick = async () => {
const r = await fetch(state.SERVER_URL + '/api/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
});
const d = await r.json();
if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
};
const declineBtn = document.createElement('button');
declineBtn.className = 'mc-friend-btn mc-friend-btn-red';
declineBtn.textContent = '✕';
declineBtn.onclick = async () => {
await fetch(state.SERVER_URL + '/api/friends/decline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
});
showToast('Отклонено');
btnFriends.onclick();
};
btns.appendChild(acceptBtn);
btns.appendChild(declineBtn);
pItem.appendChild(btns);
body.appendChild(pItem);
}
}
// Add friend
body.appendChild(document.createElement('hr'));
const addLabel = document.createElement('div');
addLabel.className = 'mc-label';
addLabel.textContent = '➕ Добавить друга по нику:';
body.appendChild(addLabel);
const addRow = document.createElement('div');
addRow.style.display = 'flex';
addRow.style.gap = '4px';
const addInput = document.createElement('input');
addInput.className = 'mc-input';
addInput.style.flex = '1';
addInput.type = 'text';
addInput.maxLength = 16;
addInput.placeholder = 'Ник друга';
const addBtn = document.createElement('button');
addBtn.className = 'mc-btn mc-btn-blue';
addBtn.style.width = 'auto';
addBtn.style.marginBottom = '0';
addBtn.style.padding = '8px 16px';
addBtn.textContent = '+';
addBtn.onclick = async () => {
const nick = addInput.value.trim().toLowerCase();
if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; }
const r = await fetch(state.SERVER_URL + '/api/friends/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tg_id: tgUser.id, nickname: nick })
});
const d = await r.json();
if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); }
else { showToast('❌ ' + (d.error || 'Ошибка')); }
};
addRow.appendChild(addInput);
addRow.appendChild(addBtn);
body.appendChild(addRow);
// Privacy checkboxes
const settings = data.settings || { allow_requests: 1, notify_online: 1 };
const settingsDiv = document.createElement('div');
settingsDiv.style.marginTop = '12px';
const reqLabel = document.createElement('label');
reqLabel.className = 'mc-checkbox-row';
const reqCb = document.createElement('input');
reqCb.className = 'mc-checkbox';
reqCb.type = 'checkbox';
reqCb.checked = settings.allow_requests;
reqCb.onchange = async () => {
await fetch(state.SERVER_URL + '/api/friends/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tg_id: tgUser.id,
allow_requests: reqCb.checked ? 1 : 0,
notify_online: settings.notify_online
})
});
};
reqLabel.appendChild(reqCb);
reqLabel.appendChild(document.createTextNode(' Запросы в друзья'));
settingsDiv.appendChild(reqLabel);
body.appendChild(settingsDiv);
} catch (e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); }
};
body.appendChild(btnFriends);
}
// Player name input (only if not already set)
if (!state.playerName) {
const nameDivider = document.createElement('hr');
nameDivider.className = 'mc-divider';
body.appendChild(nameDivider);
const nameLabel = document.createElement('div');
nameLabel.className = 'mc-label';
nameLabel.textContent = 'Твоё имя:';
body.appendChild(nameLabel);
const nameInput = document.createElement('input');
nameInput.className = 'mc-input';
nameInput.type = 'text';
nameInput.placeholder = 'Ваше имя';
nameInput.value = tgUser ? (tgUser.username || tgUser.first_name || '') : '';
nameInput.maxLength = 20;
nameInput.addEventListener('input', () => {
if (nameInput.value.trim()) {
state.playerName = nameInput.value.trim();
localStorage.setItem('minegrechka_playerName', state.playerName);
}
});
body.appendChild(nameInput);
}
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
// Pre-fill player name from TG if available
if (isTgWebApp && tgUser && !state.playerName) {
state.playerName = tgUser.username || tgUser.first_name || 'Игрок';
localStorage.setItem('minegrechka_playerName', state.playerName);
}
// Auto-load TG save info (background fetch)
if (isTgWebApp && tgUser && !worldParam) {
fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id)
.then(r => r.json())
.then(data => {
if (data.ok && data.world_id) {
// could update UI but menu is already shown
}
})
.catch(() => {});
}
});
}
// === core/canvas.js ===
// ==================== ХОЛСТ И РАЗМЕРЫ ====================
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;
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);
}
function setW(v) { W = v; }
function setH(v) { H = v; }
// === data/blocks.js ===
const BLOCKS = {
air: { n:'Воздух', solid:false },
grass: { n:'Трава', c:'#7cfc00', solid:true },
dirt: { n:'Грязь', c:'#8b4513', solid:true },
stone: { n:'Камень', c:'#7f8c8d', solid:true },
sand: { n:'Песок', c:'#f4d06f', solid:true },
gravel: { n:'Гравий', c:'#95a5a6', solid:true },
clay: { n:'Глина', c:'#74b9ff', solid:true },
wood: { n:'Дерево', c:'#d35400', solid:true },
planks: { n:'Доски', c:'#e67e22', solid:true },
ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true },
leaves: { n:'Листва', c:'#2ecc71', solid:true },
glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 },
water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true },
coal: { n:'Уголь', c:'#2c3e50', solid:true },
copper_ore:{ n:'Медь', c:'#e17055', solid:true },
iron_ore: { n:'Железо', c:'#dcdde1', solid:true },
iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 },
gold_ore: { n:'Золото', c:'#f1c40f', solid:true },
diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true },
brick: { n:'Кирпич', c:'#c0392b', solid:true },
tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true },
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190 },
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 },
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
boat: { n:'Лодка', c:'#8B4513', solid:false },
furnace: { n:'Печь', c:'#696969', solid:true, smelting:true }
};
// === world/world-storage.js ===
// Мир-хранилище
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) {
state.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 = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
if (wasPlayerPlaced) {
// Удаляем из placedBlocks
state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
} else {
// Это природный блок - добавляем в removedBlocks
state.removedBlocks.push({ gx, gy });
}
return b;
}
// === world/generation.js ===
// Генерация (по X, на всю глубину до bedrock)
const generated = state.generated; // Set of gx already generated
// ==================== БИОМЫ ====================
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 + state.worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + state.worldSeed*0.02)*0.3 + 0.5;
const humid = Math.sin(gx*0.004 + state.worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + state.worldSeed*0.02 + 2000)*0.3 + 0.5;
const mtVal = Math.sin(gx*0.001 + state.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
if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8);
return biomeCache[chunk];
}
// ==================== СТРУКТУРЫ ====================
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']
];
// ==================== ПОВЕРХНОСТЬ (BIOME-AWARE) ====================
function surfaceGyAt(gx) {
const biome = getCachedBiome(gx);
// Базовый шум (одинаков для всех биомов)
const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8;
const n2 = Math.sin(gx * 0.012 + state.worldSeed * 0.002) * 12;
const n3 = Math.sin(gx * 0.006 + state.worldSeed * 0.003) * 6;
const n4 = Math.sin(gx * 0.045 + state.worldSeed * 0.004) * 4;
const n5 = Math.cos(gx * 0.018 + state.worldSeed * 0.005) * 5;
let h;
switch(biome) {
case 'desert':
h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); // плоско, чуть выше
break;
case 'tundra':
h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); // пологие холмы
break;
case 'swamp':
h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); // очень плоско, у воды
h = Math.max(h, SEA_GY - 3); // не слишком глубоко
break;
case 'mountains':
h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); // высокие пики
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);
// цветы (только plains)
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') {
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 {
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 state.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(state.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(state.camX / TILE);
for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
genColumn(gx);
}
}
function seededRandom(gx, gy) {
const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453;
return n - Math.floor(n);
}
// === audio/sound-engine.js ===
// Звуковой движок
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));
}
}
// === ui/chat.js ===
// ==================== ЧАТ ====================
const chatMessages = [];
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 (state.isMultiplayer && state.socket && state.socket.connected) {
state.socket.emit('chat_message', { message: message.trim() });
} else {
addChatMessage('Вы', message.trim());
}
}
function initChat() {
document.getElementById('chatToggle').onclick = () => {
playSound('click');
state.chatOpen = !state.chatOpen;
document.getElementById('chatPanel').style.display = state.chatOpen ? 'block' : 'none';
if (state.chatOpen) {
document.getElementById('chatInput').focus();
}
};
document.getElementById('chatClose').onclick = () => {
playSound('click');
state.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 = '';
}
});
}
// === entities/player.js ===
// Функция для расчёта урона с учётом брони
function calculateDamage(baseDamage) {
// Броня снижает урон пропорционально
// armor: 0 = без брони (100% урона)
// armor: 0.5 = железная броня (50% урона)
const reduction = state.player.armor;
const actualDamage = baseDamage * (1 - reduction);
console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1));
return actualDamage;
}
// Инициализация игрока
function initPlayer() {
state.player = {
x: 6 * TILE,
y: 0 * TILE,
w: 34,
h: 34,
vx: 0,
vy: 0,
grounded: false,
inWater: false,
headInWater: false,
hp: 100,
hunger: 100,
o2: 100,
invuln: 0,
fallStartY: 0,
lastStepTime: 0,
sleeping: false,
inBoat: false,
armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
equippedArmor: null // Тип надетой брони
};
// Сохраняем начальную позицию для возрождения
state.spawnPoint = { x: 6 * TILE, y: 0 * TILE };
}
// === data/items.js ===
const ITEMS = {
meat: { n:'Сырое мясо', icon:'🥩', food:15 },
cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
arrow: { n:'Стрела', icon:'➡️', stack:64 },
};
// Runtime extensions — предметы от обжига
ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
// === data/tools.js ===
const TOOLS = {
wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } },
stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } },
iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } },
wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 } },
stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 } },
iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } },
bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, string: 0 } }
};
function addTool(type) {
const maxDur = TOOLS[type]?.durability || 60;
const key = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
state.toolDurability.set(key, { type, current: maxDur, max: maxDur });
}
function useTool(type) {
for (const [tid, dur] of state.toolDurability) {
if (dur.type === type && dur.current > 0) {
dur.current--;
if (dur.current <= 0) {
state.toolDurability.delete(tid);
state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1);
return true; // broke
}
return false; // used but not broke
}
}
return false; // no durability entry found
}
// === render/textures.js ===
// Текстуры блоков (простые)
const tex = {};
function makeTex(type) {
const t = BLOCKS[type];
const c = document.createElement('canvas');
c.width = 32; c.height = 32;
const g = c.getContext('2d');
if (type === 'tnt') {
g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32);
g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8);
g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20);
return c;
}
if (type === 'campfire') {
g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6);
g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4);
return c;
}
if (type === 'torch') {
g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18);
g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8);
return c;
}
if (type === 'glass') {
g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32);
g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28);
g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke();
return c;
}
if (type === 'water') {
g.fillStyle = t.c; g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(255,255,255,0.08)';
g.fillRect(0, 6, 32, 2);
return c;
}
if (type === 'bed') {
// Основание кровати
g.fillStyle = '#e91e63';
g.fillRect(0, 0, 32, 32);
// Подушка
g.fillStyle = '#f8bbd0';
g.fillRect(2, 2, 14, 14);
// Одеяло
g.fillStyle = '#c2185b';
g.fillRect(16, 4, 14, 24);
// Детали одеяла
g.fillStyle = '#e91e63';
g.fillRect(18, 6, 10, 20);
return c;
}
if (type === 'flower') {
g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18);
g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill();
return c;
}
if (type === 'boat') {
// Корпус лодки
g.fillStyle = '#8B4513';
g.fillRect(2, 12, 28, 8);
// Борта
g.fillStyle = '#A0522D';
g.fillRect(0, 10, 32, 12);
// Внутренность
g.fillStyle = '#DEB887';
g.fillRect(4, 14, 24, 4);
// Дно
g.fillStyle = '#654321';
g.fillRect(2, 20, 28, 4);
return c;
}
if (type === 'ladder') {
// Боковые стойки лестницы
g.fillStyle = '#8B4513';
g.fillRect(4, 0, 4, 32);
g.fillRect(24, 0, 4, 32);
// Ступени
g.fillStyle = '#A0522D';
g.fillRect(4, 4, 24, 3);
g.fillRect(4, 12, 24, 3);
g.fillRect(4, 20, 24, 3);
g.fillRect(4, 28, 24, 3);
return c;
}
g.fillStyle = t.c || '#000';
g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(0,0,0,0.10)';
for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4);
if (type.endsWith('_ore') || type === 'coal') {
g.fillStyle = 'rgba(0,0,0,0.35)';
for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6);
}
return c;
}
function initTextures() {
Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
}
// === ui/hotbar.js ===
// Hotbar UI
function rebuildHotbar() {
const hotbarEl = state.hotbarEl;
const inv = state.inv;
const selected = state.selected;
const recentItems = state.recentItems;
const toolDurability = state.toolDurability;
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'); // Звук клика по инвентарю
state.selected = id;
// Обновляем список последних предметов
state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть
state.recentItems.unshift(id); // Добавляем в начало
state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5
rebuildHotbar();
};
// Показываем индикатор надетой брони
if (id === 'iron_armor' && state.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);
}
}
// === render/particles.js ===
// Частицы (взрыв)
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'
});
}
}
// === world/tnt.js ===
// TNT логика: цепь + усиление
function activateTNT(b, fuse = 3.2) {
if (b.dead) return;
if (b.active) return;
b.active = true;
b.fuse = fuse;
state.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);
state.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 (state.inv[b.t] !== undefined && Math.random() < 0.20) state.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 === state.player) {
const actualDamage = calculateDamage(dmg);
state.player.hp -= actualDamage;
} else {
e.hp -= dmg;
}
e.vx += (dx / dist || 0) * 600;
e.vy -= 320;
}
};
hurt(state.player);
state.mobs.forEach(hurt);
}
// === game/save.js ===
// ==================== СИСТЕМА СОХРЕНЕНИЯ ИГРЫ ====================
let db = null; // Оставляем для совместимости, но не используем
// Инициализация (localStorage + in-memory fallback)
function initDB() {
return new Promise((resolve) => {
console.log('Используем localStorage для сохранений (sandbox режим)');
resolve(null);
});
}
function saveGame() {
// В MP не сохраняем мир — сервер хранит блоки и seed
if (state.isMultiplayer) {
console.log('[MP] Skipping world save — server is authoritative');
return;
}
const saveData = {
version: 2,
worldSeed: state.worldSeed,
player: {
x: state.player.x,
y: state.player.y,
hp: state.player.hp,
hunger: state.player.hunger,
o2: state.player.o2
},
inventory: state.inv,
time: state.worldTime,
isNight: state.isNightTime,
dayNightCycle: state.dayNightCycle,
// Сохраняем только изменения
placedBlocks: state.placedBlocks.slice(),
removedBlocks: state.removedBlocks.slice()
};
const saveSize = JSON.stringify(saveData).length;
console.log('Сохранение: player HP:', state.player.hp, 'hunger:', state.player.hunger, 'o2:', state.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
state.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 (state.inMemorySave) {
console.log('Загружено из in-memory сохранения, player HP:', state.inMemorySave.player?.hp);
resolve(state.inMemorySave);
return;
}
console.log('Сохранение не найдено');
resolve(null);
});
}
// Миграция с версии 1 на версию 2
function migrateV1toV2(saveData) {
console.log('Миграция сохранения с версии 1 на версию 2...');
// Сохраняем seed из текущей игры (так как v1 его не хранил)
saveData.worldSeed = state.worldSeed;
// Инициализируем массивы изменений
saveData.placedBlocks = [];
saveData.removedBlocks = [];
// Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed
// Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed
// и при загрузке просто перегенерируем мир
// Удаляем старые данные
delete saveData.generatedBlocks;
saveData.version = 2;
console.log('Миграция завершена');
}
export async function applySave(saveData) {
if (!saveData) return;
console.log('=== applySave START ===');
console.log('player HP before applySave:', state.player.hp);
console.log('saveData.player.hp:', saveData.player?.hp);
console.log('isMultiplayer:', state.isMultiplayer);
// Миграция версий
if (saveData.version === 1) {
migrateV1toV2(saveData);
}
// В MP режиме сервер — истина, не даём локальному сейву перетирать мир
if (state.isMultiplayer) {
console.log('[MP] Skipping world seed/block restore from local save — server is authoritative');
// Восстанавливаем только игрока и инвентарь, мир не трогаем
if (saveData.player) {
state.player.hunger = saveData.player.hunger;
state.player.o2 = saveData.player.o2;
state.player.hp = (saveData.player.hp > 0) ? saveData.player.hp : 100;
console.log('Restored player state from save (hp, hunger, o2)');
}
if (saveData.inventory) {
for (const key in saveData.inventory) {
state.inv[key] = saveData.inventory[key];
}
console.log('Restored inventory from save');
}
// Fill recentItems from inventory if empty
if (state.recentItems.length === 0) {
state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5);
}
rebuildHotbar();
console.log('=== applySave END (MP mode, world skipped) ===');
return;
}
// Восстанавливаем seed (только одиночная игра)
if (saveData.worldSeed !== undefined) {
state.worldSeed = saveData.worldSeed;
}
// Восстанавливаем игрока
if (saveData.player) {
state.player.x = saveData.player.x;
state.player.y = saveData.player.y;
state.player.hunger = saveData.player.hunger;
state.player.o2 = saveData.player.o2;
// Обновляем spawnPoint на позицию из сохранения
state.spawnPoint.x = state.player.x;
state.spawnPoint.y = state.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!');
state.player.hp = 100;
} else {
state.player.hp = savedHP;
}
console.log('player HP after restore:', state.player.hp);
console.log('spawnPoint обновлён из сохранения: x=', state.spawnPoint.x, 'y=', state.spawnPoint.y);
} else {
console.log('No player data in save, setting default HP: 100');
state.player.hp = 100;
}
console.log('=== applySave END ===');
// Восстанавливаем инвентарь
if (saveData.inventory) {
for (const key in saveData.inventory) {
state.inv[key] = saveData.inventory[key];
}
}
// Восстанавливаем время
if (saveData.time !== undefined) {
state.worldTime = saveData.time;
}
// Восстанавливаем день/ночь
if (saveData.isNight !== undefined) {
state.isNightTime = saveData.isNight;
}
if (saveData.dayNightCycle !== undefined) {
state.dayNightCycle = saveData.dayNightCycle;
}
// Перегенерируем мир по 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);
}
// Восстанавливаем массивы изменений
state.placedBlocks = saveData.placedBlocks || [];
state.removedBlocks = saveData.removedBlocks || [];
}
// Fill recentItems from inventory if empty (hotbar won't render otherwise)
if (state.recentItems.length === 0) {
state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5);
}
rebuildHotbar();
console.log('Игра загружена');
}
// === ui/save-controls.js ===
// ==================== МЕНЮ ⋯ + СОХРАНЕНИЕ / СБРОС ====================
let dropdownOpen = false;
function initSaveControls() {
const menuBtn = document.getElementById('menuBtn');
const menuDropdown = document.getElementById('menuDropdown');
const saveItem = document.getElementById('saveItem');
const resetItem = document.getElementById('resetItem');
const marketItem = document.getElementById('marketItem');
const settingsItem = document.getElementById('settingsItem');
// Убить возможный старый onclick (предотвращает race condition)
menuBtn.onclick = null;
// Тогл меню
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
dropdownOpen = !dropdownOpen;
menuDropdown.classList.toggle('open', dropdownOpen);
});
// Закрытие по клику вне
document.addEventListener('click', () => {
if (dropdownOpen) {
dropdownOpen = false;
menuDropdown.classList.remove('open');
}
});
// Предотвращаем закрытие при клике внутри дропдауна
menuDropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
// 💾 Сохранить
saveItem.addEventListener('click', () => {
playSound('click');
saveGame();
dropdownOpen = false;
menuDropdown.classList.remove('open');
alert('Игра сохранена!');
});
// 🔄 Новый мир
resetItem.addEventListener('click', () => {
if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
playSound('click');
try {
localStorage.removeItem(state.SAVE_KEY);
console.log('Сохранение удалено из localStorage');
} catch (e) {
console.warn('Ошибка удаления сохранения:', e);
}
state.inMemorySave = null;
state.worldId = Math.random().toString(36).substring(2, 10);
console.log('Новый worldId после сброса:', state.worldId);
try {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('world', state.worldId);
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
window.history.replaceState(null, '', newUrl.toString());
console.log('URL обновлён:', newUrl.toString());
}
} catch (e) {
console.error('Ошибка обновления URL:', e);
}
location.reload();
} else {
dropdownOpen = false;
menuDropdown.classList.remove('open');
}
});
// 🏪 Рынок
marketItem.addEventListener('click', () => {
dropdownOpen = false;
menuDropdown.classList.remove('open');
// toggleMarket импортируется в main.js, вызовем через кастомное событие
document.dispatchEvent(new CustomEvent('toggleMarket'));
});
// ⚙️ Настройки
if (settingsItem) {
settingsItem.addEventListener('click', () => {
dropdownOpen = false;
menuDropdown.classList.remove('open');
document.dispatchEvent(new CustomEvent('toggleSettings'));
});
}
}
// Показываем/скрываем пункт сохранения в зависимости от режима
function updateSaveButtonVisibility() {
const saveItem = document.getElementById('saveItem');
if (!saveItem) return;
if (state.isMultiplayer && state.otherPlayers.size > 0) {
saveItem.style.display = 'none';
} else {
saveItem.style.display = 'block';
}
}
// === multiplayer/socket-helpers.js ===
// Socket helpers: цвет, отправка позиции/блоков
// Генерация случайного цвета для игрока на основе 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 для отправки позиции (10-20 раз в секунду)
let lastMoveSendTime = 0;
const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
let lastSentX = 0, lastSentY = 0;
// Отправка позиции игрока (с throttle)
function sendPlayerPosition() {
if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
const now = performance.now() / 1000;
if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
// Отправляем только если позиция изменилась
const dx = Math.abs(state.player.x - lastSentX);
const dy = Math.abs(state.player.y - lastSentY);
if (dx < 1 && dy < 1) return;
lastMoveSendTime = now;
lastSentX = state.player.x;
lastSentY = state.player.y;
state.socket.emit('player_move', { x: state.player.x, y: state.player.y, player_name: state.playerName });
}
// Отправка изменения блока
function sendBlockChange(gx, gy, t, op) {
if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
state.socket.emit('block_change', { gx, gy, t, op });
}
// === multiplayer/socket.js ===
// Socket.IO клиент — инициализация и все обработчики
let socket = null;
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}`);
state.mySocketId = socket.id;
state.isMultiplayer = true;
// Присоединяемся к миру
socket.emit('join_world', { world_id: worldId, player_name: playerName });
// Показываем в UI
state.worldIdEl.textContent = worldId;
state.multiplayerStatus.style.display = 'block';
});
socket.on('connect_error', (error) => {
console.error('Socket connection error:', error);
state.isMultiplayer = false;
});
socket.on('disconnect', () => {
console.log('Disconnected from server');
state.isMultiplayer = false;
state.otherPlayers.clear();
state.multiplayerStatus.style.display = 'none';
});
// Обработка world_state
socket.on('world_state', (data) => {
console.log('Received world_state:', data);
// Устанавливаем seed и перегенерируем мир если он изменился
if (data.seed !== undefined && data.seed !== state.worldSeed) {
const oldSeed = state.worldSeed;
state.worldSeed = data.seed;
console.log('World seed changed from', oldSeed, 'to', state.worldSeed);
// Очищаем и перегенерируем мир с новым seed
state.generated.clear();
grid.clear();
blocks.length = 0;
state.placedBlocks = [];
state.removedBlocks = [];
console.log('World regenerated with new seed:', state.worldSeed);
}
// Применяем блоки — сохраняем в state.serverOverrides для применения после genColumn
if (data.blocks && Array.isArray(data.blocks)) {
for (const block of data.blocks) {
const key = k(block.gx, block.gy);
state.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) {
state.worldTime = data.time;
state.isNightTime = state.worldTime > 0.5;
}
// Погода с сервера (мультиплеер)
if (data.weatherType) {
state.weatherType = data.weatherType;
state.weatherChangeInterval = Infinity;
state.isRaining = (data.weatherType === 'rain' || data.weatherType === 'storm' || data.weatherType === 'thunder');
}
// Всегда считаем 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;
}
}
state.spawnPoint.x = startGX * TILE;
state.spawnPoint.y = safeGY * TILE;
console.log('Client-side spawn point:', state.spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY);
}
// Устанавливаем игрока в точку спавна
state.player.x = state.spawnPoint.x;
state.player.y = state.spawnPoint.y;
state.player.vx = 0;
state.player.vy = 0;
state.player.fallStartY = state.player.y;
console.log('Player moved to spawn point:', state.player.x, state.player.y);
// Устанавливаем HP на 100% при каждом подключении к миру
state.player.hp = 100;
state.player.hunger = 100;
state.player.o2 = 100;
state.player.invuln = 0;
console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
// Обновляем список игроков
if (data.players && Array.isArray(data.players)) {
state.otherPlayers.clear();
for (const p of data.players) {
if (p.socket_id !== state.mySocketId) {
state.otherPlayers.set(p.socket_id, {
x: p.x,
y: p.y,
color: getRandomPlayerColor(p.socket_id),
name: p.player_name || 'Игрок'
});
}
}
// Обновляем счётчик игроков
state.playerCountEl.textContent = data.players.length;
}
// Server mobs
if (data.mobs && Array.isArray(data.mobs)) {
state.serverMobs.clear();
for (const m of data.mobs) {
const sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 };
state.serverMobs.set(m.id, sm);
}
}
});
// Игрок присоединился
socket.on('player_joined', (data) => {
console.log('Player joined:', data.socket_id);
if (data.socket_id !== state.mySocketId) {
// Генерируем безопасную позицию для нового игрока
const spawnGX = 6;
genColumn(spawnGX);
const surfaceY = surfaceGyAt(spawnGX);
const safeSpawnX = spawnGX * TILE;
const safeSpawnY = (surfaceY - 1) * TILE;
state.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 !== state.mySocketId && state.otherPlayers.has(data.socket_id)) {
const p = state.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);
state.otherPlayers.delete(data.socket_id);
addChatMessage('Система', `Игрок покинул игру`);
// Обновляем видимость кнопки сохранения
updateSaveButtonVisibility();
});
// === MOB SYNC (multiplayer) ===
socket.on('mob_spawned', (data) => {
const sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 };
state.serverMobs.set(data.id, sm);
});
socket.on('mob_positions', (arr) => {
for (const u of arr) {
const sm = state.serverMobs.get(u.id);
if (sm) { sm.x = u.x; sm.y = u.y; sm.vx = u.vx; sm.vy = u.vy; sm.dir = u.dir; sm.hp = u.hp; sm.fuse = u.fuse || 0; }
}
});
socket.on('mob_despawned', (data) => { state.serverMobs.delete(data.id); });
socket.on('mob_died', (data) => {
const sm = state.serverMobs.get(data.id);
if (sm && data.killer === state.mySocketId) {
// Give loot to the killer
if (sm.kind === 'chicken') playSound('hurt_chicken');
state.inv.meat += (sm.kind === 'chicken' ? 1 : 2);
if (sm.kind === 'skeleton') {
state.inv.arrow += 2 + Math.floor(Math.random() * 3);
if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
}
rebuildHotbar();
}
state.serverMobs.delete(data.id);
});
socket.on('mob_hurt_ack', (data) => {
const sm = state.serverMobs.get(data.id);
if (sm) sm.hp = data.hp;
});
socket.on('mob_explode', (data) => {
explodeAt(data.gx, data.gy);
state.serverMobs.delete(data.id);
});
socket.on('mob_shoot', (data) => {
state.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);
state.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 === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
addChatMessage(senderName, data.message);
});
// Обновление времени с сервера
socket.on('time_sync', (data) => {
if (data.time !== undefined) {
state.worldTime = data.time;
state.isNightTime = state.worldTime > 0.5;
}
});
// Погода с сервера (мультиплеер)
socket.on('weather_update', (data) => {
if (!state.isMultiplayer) return;
if (data.type) {
state.weatherType = data.type;
state.weatherChangeInterval = Infinity;
state.isRaining = (data.type === 'rain' || data.type === 'storm' || data.type === 'thunder');
}
});
// ==================== РЫНОК ====================
socket.on('market_update', (data) => {
// Update local market cache if needed
// The market UI will refresh automatically when opened
});
socket.on('market_error', (data) => {
alert(data.message || 'Ошибка рынка');
});
socket.on('market_accepted', (data) => {
// Notify user about successful trade
if (data.gotItem && data.gaveItem) {
const gotName = BLOCKS[data.gotItem]?.n || ITEMS[data.gotItem]?.n || TOOLS[data.gotItem]?.n || data.gotItem;
const gaveName = BLOCKS[data.gaveItem]?.n || ITEMS[data.gaveItem]?.n || TOOLS[data.gaveItem]?.n || data.gaveItem;
if (data.gotItem === 'iron_armor') gotName = 'Железная броня';
if (data.gaveItem === 'iron_armor') gaveName = 'Железная броня';
alert(`Обмен успешен! Вы получили: ${gotName} ×${data.gotQty}\nВы отдали: ${gaveName} ×${data.gaveQty}`);
} else {
alert('Обмен успешен!');
}
// Refresh market if open
if (state.marketPanel && state.marketPanel.style.display === 'block') {
renderMarket();
}
});
} catch (e) {
console.error('Error initializing socket:', e);
state.isMultiplayer = false;
}
}
}
// === world/water.js ===
// Физика жидкости
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(state.camX / TILE) - 10;
const maxGX = Math.floor((state.camX + W) / TILE) + 10;
const minGY = Math.floor(state.camY / TILE) - 10;
const maxGY = Math.floor((state.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);
}
}
}
// === physics/water-detect.js ===
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 === state.player && !wasInWater && e.inWater && e.vy > 100) {
playSound('splash');
}
}
// === physics/collision.js ===
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 (state.inp.jump) {
e.vy = -200;
}
// Если нажимаем вниз - спускаемся
else if (state.inp.down) {
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) && state.inp.jump && 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 === state.player && !state.player.inWater) {
const fallTiles = (e.y - e.fallStartY) / TILE;
if (fallTiles > 6) {
const damage = calculateDamage((fallTiles - 6) * 10);
state.player.hp -= damage;
}
}
if (e === state.player) e.fallStartY = e.y;
}
}
// 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним
if (e.vy < 0 && e === state.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 === state.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;
}
}
}
// === entities/mob-ai.js ===
// Моб 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((state.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) - (state.player.x + state.player.w / 2)) < 28 &&
Math.abs((m.y + m.h / 2) - (state.player.y + state.player.h / 2)) < 40 &&
state.player.invuln <= 0) {
const damage = calculateDamage(15);
state.player.hp -= damage;
state.player.invuln = 0.8;
state.player.vx += dir * 420;
state.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((state.player.x) - m.x);
const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.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((state.player.x) - m.x);
const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.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 = (state.player.x + state.player.w / 2) - (m.x + m.w / 2);
const dy = (state.player.y + state.player.h / 2) - (m.y + m.h / 2);
const angle = Math.atan2(dy, dx);
const speed = 450;
state.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 {
// животные
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 state.worldTime > 0.5;
}
// === data/recipes.js ===
const RECIPES = [
{ out:'planks', qty:4, cost:{ wood:1 } },
{ out:'ladder', qty:3, cost:{ planks:7 } },
{ out:'torch', qty:2, cost:{ coal:1, planks:1 } },
{ out:'glass', qty:1, cost:{ sand:3 } },
{ out:'brick', qty:1, cost:{ stone:2, clay:1 } },
{ out:'campfire', qty:1, cost:{ wood:1, coal:1 } },
{ out:'tnt', qty:1, cost:{ sand:2, coal:1 } },
{ out:'bed', qty:1, cost:{ wood: 3, planks: 3 } },
{ out:'boat', qty:1, cost:{ wood: 5 } },
{ out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } },
{ out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } },
{ out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } },
{ out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } },
{ out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } },
{ out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } },
{ out:'iron_armor', qty:1, cost:{ iron_ore: 5 } },
{ out:'furnace', qty:1, cost:{ stone: 8 } },
{ out:'bow', qty:1, cost:{ wood: 3, planks: 2 } },
{ out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } }
];
// Рецепты печи (обжиг)
const SMELTING_RECIPES = [
{ in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло
{ in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич
{ in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток
{ in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток
{ in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток
{ in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное
{ in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 } // булыжник → камень
];
// === ui/furnace.js ===
// ==================== ПЕЧЬ (ОБЖИГ) ====================
const furnacePanel = document.getElementById('furnacePanel');
const furnaceContent = document.getElementById('furnaceContent');
function initFurnace() {
document.getElementById('furnaceClose').onclick = () => {
furnacePanel.style.display = 'none';
state.currentFurnaceKey = null;
};
}
function openFurnaceUI(gx, gy) {
state.currentFurnaceKey = `${gx},${gy}`;
furnacePanel.style.display = 'block';
renderFurnaceUI();
}
function renderFurnaceUI() {
if (!state.currentFurnaceKey) return;
// Проверяем что печь всё ещё существует
const [fgx, fgy] = state.currentFurnaceKey.split(',').map(Number);
const fb = getBlock(fgx, fgy);
if (!fb || fb.t !== 'furnace') {
furnacePanel.style.display = 'none';
state.currentFurnaceKey = null;
return;
}
// Текущий процесс обжига
const active = state.activeFurnaces.get(state.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 = state.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 (!state.currentFurnaceKey) return;
const recipe = SMELTING_RECIPES[recipeIdx];
if ((state.inv[recipe.in] || 0) < recipe.qty) return;
// Уже обжигаем в этой печи?
if (state.activeFurnaces.has(state.currentFurnaceKey)) return;
// Забираем ресурсы
state.inv[recipe.in] -= recipe.qty;
// Запускаем обжиг
state.activeFurnaces.set(state.currentFurnaceKey, {
recipe: recipe,
progress: 0
});
playSound('fire');
rebuildHotbar();
renderFurnaceUI();
};
// Тик печей — вызывается в главном цикле
function tickFurnaces(dt) {
for (const [key, furnace] of state.activeFurnaces) {
furnace.progress += dt;
if (furnace.progress >= furnace.recipe.time) {
// Обжиг завершён — выдаём результат
const outItem = furnace.recipe.out;
if (ITEMS[outItem]) {
state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
} else if (BLOCKS[outItem]) {
state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
}
playSound('stone_build');
state.activeFurnaces.delete(key);
// Если эта печь открыта — обновляем UI
if (key === state.currentFurnaceKey) {
renderFurnaceUI();
}
}
}
}
// === world/weather.js ===
// ==================== ПОГОДА ====================
function updateWeather(dt) {
state.weatherTimer += dt;
if (state.weatherTimer >= state.weatherChangeInterval) {
state.weatherTimer = 0;
state.weatherChangeInterval = 60 + Math.random() * 120;
// Выбираем новую погоду: 40% дождь днём, 25% дождь ночью, остальное ясно
const nightChance = isNight() ? 0.25 : 0.40;
state.isRaining = Math.random() < nightChance;
}
// Плавная интерполяция интенсивности
const target = state.isRaining ? (0.4 + Math.random() * 0.01) : 0;
state.rainIntensity += (target - state.rainIntensity) * dt * 0.5;
if (state.rainIntensity < 0.01) state.rainIntensity = 0;
}
function updateRain(dt) {
if (!state.isRaining || state.rainIntensity < 0.01) {
state.raindrops.length = 0;
return;
}
// Спавн капель
const spawnRate = Math.floor(state.rainIntensity * 60 * dt); // ~60 капель/сек при интенсити=1
for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) {
state.raindrops.push({
x: state.camX + Math.random() * state.W,
y: state.camY - 20,
vy: 400 + Math.random() * 200,
len: 8 + Math.random() * 12
});
}
// Обновление
for (let i = state.raindrops.length - 1; i >= 0; i--) {
const d = state.raindrops[i];
d.y += d.vy * dt;
d.x -= 30 * dt; // лёгкий ветер
if (d.y > state.camY + state.H + 20) {
state.raindrops.splice(i, 1);
}
}
}
function drawRain() {
if (state.raindrops.length === 0) return;
const ctx = state.ctx;
ctx.save();
ctx.strokeStyle = 'rgba(174,194,224,0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (const d of state.raindrops) {
ctx.moveTo(d.x, d.y);
ctx.lineTo(d.x - 3, d.y + d.len);
}
ctx.stroke();
ctx.restore();
}
// === entities/mobs.js ===
// Сущности: животные + зомби
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; }
}
// === ui/minimap.js ===
// ==================== МИНИКАРТА ====================
const minimapWrap = document.getElementById('minimapWrap');
const minimapCanvas = document.getElementById('minimap');
const minimapCtx = minimapCanvas.getContext('2d');
// Цвета блоков для миникарты (по 1 пикселю на блок)
const MINIMAP_COLORS = {
grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410'
};
function initMinimap() {
document.getElementById('mapToggle').onclick = () => {
playSound('click');
state.minimapOpen = !state.minimapOpen;
minimapWrap.style.display = state.minimapOpen ? 'block' : 'none';
};
}
function renderMinimap() {
if (!state.minimapOpen) return;
const mW = minimapCanvas.width;
const mH = minimapCanvas.height;
const scale = 2; // пикселей на блок
const TILE = state.TILE;
const player = state.player;
// Область карты — центрирована на игроке
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 state.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 = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
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);
}
}
}
// === game/modes.js ===
// ==================== РЕЖИМЫ ====================
const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }];
function mode() {
return MODES[state.modeIdx].id;
}
function initModes() {
const modeBtn = document.getElementById('modeBtn');
modeBtn.onclick = () => {
playSound('click'); // Звук клика по кнопке режима
state.modeIdx = (state.modeIdx + 1) % MODES.length;
modeBtn.textContent = MODES[state.modeIdx].icon;
};
}
// === input/mouse-handler.js ===
// ==================== ВЗАИМОДЕЙСТВИЕ МЫШЬ/ТАП ====================
const mouse = { x: null, y: null };
function initMouseHandlers() {
const canvas = state.canvas;
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 (state.craftOpen) return;
if (state.player.hp <= 0) return;
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left;
const sy = e.clientY - r.top;
const wx = sx + state.camX;
const wy = sy + state.camY;
const gx = Math.floor(wx / state.TILE);
const gy = Math.floor(wy / state.TILE);
// Пробуждение: клик по любой кровати когда спишь
const b = getBlock(gx, gy);
if (state.player.sleeping && b && b.t === 'bed') {
state.player.sleeping = false;
return;
}
if (state.player.sleeping) return; // Нельзя взаимодействовать во время сна
// Клик по печи — открываем панель обжига
if (b && b.t === 'furnace' && mode() === 'mine') {
openFurnaceUI(gx, gy);
return;
}
// клик по мобу (в режиме mine)
if (mode() === 'mine') {
// Check server mobs first (multiplayer)
if (state.isMultiplayer) {
for (const [id, sm] of state.serverMobs) {
if (sm.dead) continue;
if (wx >= sm.x && wx <= sm.x + sm.w && wy >= sm.y && wy <= sm.y + sm.h) {
let dmg = 1;
const swordTypes = ['iron_sword', 'stone_sword', 'wood_sword'];
for (const st of swordTypes) {
if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
}
state.socket.emit('mob_hurt', { id: sm.id, dmg });
playSound('attack');
return;
}
}
}
// Local mobs (singleplayer or if not hit server mob)
for (let i = state.mobs.length - 1; i >= 0; i--) {
const m = state.mobs[i];
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 (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
}
m.hp -= dmg;
m.vx += (m.x - state.player.x) * 2;
m.vy -= 200;
playSound('attack');
if (m.hp <= 0) {
if (m.kind === 'chicken') playSound('hurt_chicken');
state.inv.meat += (m.kind === 'chicken' ? 1 : 2);
if (m.kind === 'skeleton') {
state.inv.arrow += 2 + Math.floor(Math.random() * 3);
if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
}
state.mobs.splice(i, 1);
rebuildHotbar();
}
return;
}
}
}
// Лук — стреляем стрелой
if (state.selected === 'bow' && state.inv.bow > 0 && state.inv.arrow > 0) {
const aimX = wx - state.player.x - state.player.w / 2;
const aimY = wy - state.player.y - state.player.h / 2;
const angle = Math.atan2(aimY, aimX);
state.projectiles.push({
x: state.player.x + state.player.w / 2,
y: state.player.y + state.player.h / 3,
vx: Math.cos(angle) * 550,
vy: Math.sin(angle) * 550,
dmg: 10,
owner: 'player',
life: 4
});
state.inv.arrow--;
useTool('bow');
playSound('hit1');
rebuildHotbar();
return;
}
// еда (предмет)
if (ITEMS[state.selected] && state.inv[state.selected] > 0) {
const it = ITEMS[state.selected];
if (state.player.hp < 100 || state.player.hunger < 100) {
playSound('eat1'); // Звук употребления еды
state.player.hunger = Math.min(100, state.player.hunger + it.food);
state.player.hp = Math.min(100, state.player.hp + 15);
state.inv[state.selected]--;
rebuildHotbar();
}
return;
}
// жарка на костре: выбран meat + клик по campfire
if (b && b.t === 'campfire' && state.selected === 'meat' && state.inv.meat > 0) {
playSound('fire'); // Звук при жарке на костре
state.inv.meat--;
state.inv.cooked++;
rebuildHotbar();
return;
}
// Сон на кровати: клик по bed
if (b && b.t === 'bed' && isNight()) {
state.player.sleeping = true;
saveGame(); // Сохраняем при отходе ко сну
return;
}
if (mode() === 'mine') {
if (!b) return;
if (BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return;
if (b.t === 'tnt') { activateTNT(b, 3.2); return; } // не взрывается сразу
const removed = removeBlock(gx, gy);
if (removed) {
state.inv[removed.t] = (state.inv[removed.t] || 0) + 1;
// Тратим прочность кирки (если есть в инвентаре)
const pickTypes = ['iron_pickaxe', 'stone_pickaxe', 'wood_pickaxe'];
for (const pt of pickTypes) {
if (state.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 (state.inv[state.selected] <= 0) return;
if (!BLOCKS[state.selected]) return;
if (b) return; // занято
// Проверяем, ставим ли лодку
if (state.selected === 'boat') {
// Лодку можно ставить только на воду
const waterBelow = getBlock(gx, gy + 1);
if (!waterBelow || waterBelow.t !== 'water') {
return;
}
// Создаём лодку
state.boat.x = gx * state.TILE;
state.boat.y = gy * state.TILE;
state.boat.vx = 0;
state.boat.vy = 0;
state.boat.active = true;
state.boat.inWater = true;
// Сажаем игрока в лодку
state.player.inBoat = true;
state.player.x = state.boat.x;
state.player.y = state.boat.y;
state.player.vx = 0;
state.player.vy = 0;
playSound('splash');
state.inv[state.selected]--;
rebuildHotbar();
return;
}
// запрет ставить в игрока
const TILE = state.TILE;
const bx = gx * TILE, by = gy * TILE;
const overlap = !(bx >= state.player.x + state.player.w || bx + TILE <= state.player.x || by >= state.player.y + state.player.h || by + TILE <= state.player.y);
if (overlap) return;
setBlock(gx, gy, state.selected, true); // true = блок установлен игроком
state.inv[state.selected]--;
// Отправляем изменение блока на сервер
sendBlockChange(gx, gy, state.selected, 'set');
// Звук при строительстве
if (state.selected === 'stone' || state.selected === 'brick') playSound('stone_build');
else if (state.selected === 'wood' || state.selected === 'planks') playSound('wood_build');
else if (state.selected === 'glass') playSound('glass1');
else if (state.selected === 'sand') playSound('sand1');
else if (state.selected === 'snow') playSound('snow1');
else if (state.selected === 'dirt' || state.selected === 'grass') playSound('cloth1');
rebuildHotbar();
return;
}
});
}
// === render/draw-fire.js ===
// Рисование костра: огонь поверх текстуры
function drawFire(ctx, 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();
}
// === game/loop.js ===
// ==================== ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ ====================
// ==================== 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 player = state.player;
const inv = state.inv;
const inp = state.inp;
const clouds = state.clouds;
const mobs = state.mobs;
const projectiles = state.projectiles;
const parts = state.parts;
const boat = state.boat;
const jumpPressed = inp.j && !prevJump;
prevJump = inp.j;
// В MP время синхронизируется с сервера
if (state.isMultiplayer) {
// Сон всё ещё работает локально (только лечение, без ускорения времени)
if (player.sleeping && isNight()) {
player.hp = Math.min(100, player.hp + dt * 20);
if (!isNight()) {
player.sleeping = false;
}
}
} else {
// Ускорение времени во время сна
if (player.sleeping && isNight()) {
state.worldTime += dt * 8 / state.DAY_LEN; // В 8 раз быстрее
// Восстанавливаем здоровье во время сна
player.hp = Math.min(100, player.hp + dt * 20);
// Автоматическое пробуждение когда наступает день
if (!isNight()) {
player.sleeping = false;
}
} else {
state.worldTime += dt / state.DAY_LEN;
}
if (state.worldTime >= 1) state.worldTime -= 1;
}
// камера следует за игроком по X/Y
state.camX = Math.floor((player.x + player.w / 2) - state.W / 2);
state.camY = Math.floor((player.y + player.h / 2) - state.H / 2);
ensureGenAroundCamera();
// clouds parallax
for (const c of clouds) {
c.x -= c.s * dt;
if (c.x + c.w < state.camX - 400) c.x = state.camX + state.W + Math.random() * 700;
}
// player
updateWaterFlag(player);
// кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем
if (player.headInWater) {
player.o2 = Math.max(0, player.o2 - 6 * dt); // замедлил в 3.7 раза
if (player.o2 === 0) {
const damage = calculateDamage(4 * dt);
player.hp -= damage;
}
} else {
player.o2 = Math.min(100, player.o2 + 10 * dt); // замедлил восстановление в 4 раза
}
// голод убывает, но HP не отнимает (как просили)
player.hunger = Math.max(0, player.hunger - dt * 0.2); // замедлил в 4 раза
// Игрок не может двигаться во время сна
if (player.sleeping) {
player.vx = 0;
player.vy = 0;
} else {
const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
if (dir) player.vx = dir * state.MOVE;
else player.vx *= 0.82;
}
// Звук шагов при движении по земле
if (player.grounded && !player.inWater && Math.abs(player.vx) > 50) {
const stepInterval = 0.35; // Интервал между шагами в секундах
if (now / 1000 - player.lastStepTime > stepInterval) {
playSound('step');
player.lastStepTime = now / 1000;
}
}
// прыжок/плавание (новая логика)
if (player.inBoat) {
// Игрок в лодке - лодка следует за игроком
const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
if (dir) boat.vx = dir * state.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 += state.TILE; // Прыгаем из лодки
player.vy = -state.JUMP * 0.5;
playSound('splash');
}
} else if (player.inWater) {
// сопротивление в воде
player.vx *= 0.90;
player.vy *= 0.92;
// Если не нажимаем прыжок - тонем (гравитация в воде)
if (!jumpPressed && !inp.j) {
// Применяем гравитацию в воде - игрок тонет
player.vy += state.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 = -state.JUMP;
player.grounded = false;
player.fallStartY = player.y;
}
}
// Гравитация применяется только вне воды и вне лодки
if (!player.inWater && !player.inBoat) {
player.vy += state.GRAV * dt;
}
// Обновляем позицию лодки
if (boat.active) {
boat.x += boat.vx * dt;
boat.y += boat.vy * dt;
// Лодка не выходит за пределы воды
const boatGX = Math.floor(boat.x / state.TILE);
const boatGY = Math.floor(boat.y / state.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 += state.TILE;
player.vy = -200;
playSound('splash');
}
}
// Проверяем, не доплыл ли игрок из лодки
if (player.inBoat && !boat.active) {
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
player.y += state.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);
// Погода и дождь (в MP погода с сервера)
if (!state.isMultiplayer) { updateWeather(dt); }
updateRain(dt);
player.invuln = Math.max(0, player.invuln - dt);
// Voice position update
state.voicePosT += dt;
if (state.voicePosT > 0.5 && state.voiceSocket && state.voiceSocket.connected) {
state.voicePosT = 0;
state.voiceSocket.emit('voice_pos', { x: player.x, y: player.y });
}
// Furnace tick
tickFurnaces(dt);
// Обновляем UI печи если открыта
if (state.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 / state.TILE);
const gy = Math.floor(p.y / state.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 {
// Попал в моба — server mobs first in multiplayer
let hitMob = false;
if (state.isMultiplayer) {
for (const [id, sm] of state.serverMobs) {
if (sm.dead) continue;
if (p.x > sm.x && p.x < sm.x + sm.w && p.y > sm.y && p.y < sm.y + sm.h) {
state.socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx });
projectiles.splice(i, 1);
hitMob = true;
break;
}
}
}
if (!hitMob) {
// Local mobs
for (let j = mobs.length - 1; j >= 0; j--) {
const m = mobs[j];
if (p.x > m.x && p.x < m.x + m.w && p.y > m.y && p.y < m.y + m.h) {
m.hp -= p.dmg;
m.vx += p.vx * 0.2;
m.vy -= 200;
if (m.hp <= 0) {
inv.meat += (m.kind === 'chicken' ? 1 : 2);
if (m.kind === 'skeleton') {
inv.arrow += 2 + Math.floor(Math.random() * 3);
if (Math.random() < 0.15) inv.bow = (inv.bow || 0) + 1;
}
mobs.splice(j, 1);
rebuildHotbar();
}
projectiles.splice(i, 1);
break;
}
}
}
}
// Таймаут
if (p.life <= 0) projectiles.splice(i, 1);
}
// TNT tick
for (const key of Array.from(state.activeTNT)) {
const b = state.grid.get(key);
if (!b || b.dead) { state.activeTNT.delete(key); continue; }
b.fuse -= dt;
if (b.fuse <= 0) {
explodeAt(b.gx, b.gy);
}
}
// mobs spawn (с обеих сторон камеры) — только в одиночном режиме
state.spawnT += dt;
if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) {
state.spawnT = 0;
// Выбираем сторону спавна (левая или правая)
const spawnLeft = Math.random() < 0.5;
const gx = spawnLeft
? Math.floor((state.camX - 200) / state.TILE)
: Math.floor((state.camX + state.W + 200) / state.TILE);
genColumn(gx);
const sgy = surfaceGyAt(gx);
const wx = gx * state.TILE + 4;
const wy = (sgy - 2) * state.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 — только локальные (singleplayer)
if (!state.isMultiplayer) {
for (let i = mobs.length - 1; i >= 0; i--) {
const m = mobs[i];
mobAI(m, dt);
if (m.hp <= 0) mobs.splice(i, 1);
}
}
// particles
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
p.t -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += state.GRAV * dt;
if (p.t <= 0) parts.splice(i, 1);
}
// death
if (player.hp <= 0) {
state.deathEl.style.display = 'flex';
} else if (state.deathEl.style.display === 'flex') {
// Если HP > 0 но экран смерти всё ещё показан - скрываем его
state.deathEl.style.display = 'none';
}
// ==================== RENDER ====================
const ctx = state.ctx;
const W = state.W;
const H = state.H;
const camX = state.camX;
const camY = state.camY;
const TILE = state.TILE;
const tex = state.tex;
const night = isNight();
// sky
ctx.fillStyle = night ? '#070816' : (state.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;
const blocks = state.blocks;
// 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(ctx, b.gx * TILE, b.gy * TILE, now);
}
// Печь — огонь когда обжигает
if (b.t === 'furnace' && state.activeFurnaces.has(`${b.gx},${b.gy}`)) {
drawFire(ctx, b.gx * TILE + 8, b.gy * TILE + 5, now);
}
}
// mobs
const allMobsRender = state.isMultiplayer ? Array.from(state.serverMobs.values()) : mobs;
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();
}
}
// 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 state.otherPlayers) {
if (state.heroImg.complete) {
ctx.drawImage(state.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 (state.heroImg.complete) {
ctx.drawImage(state.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 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 && !state.craftOpen && player.hp > 0) {
const wx = mouse.x + camX;
const wy = mouse.y + camY;
const ggx = Math.floor(wx / TILE);
const ggy = Math.floor(wy / TILE);
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 2;
ctx.strokeRect(ggx * TILE, ggy * TILE, TILE, TILE);
}
ctx.restore();
// lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker)
if (night) {
const lightC = state.lightC;
const lightCtx = state.lightCtx;
lightC.width = W * state.dpr;
lightC.height = H * state.dpr;
lightCtx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
lightCtx.fillRect(0, 0, W, H);
lightCtx.globalCompositeOperation = 'destination-out';
function castLight(sx, sy, radius) {
const flick = 0.88 + Math.sin(now / 80 + sx * 0.01) * 0.06 + Math.sin(now / 130 + sy * 0.02) * 0.06;
const r = radius * flick;
const steps2 = 12;
const dists = new Float32Array(steps2);
for (let i = 0; i < steps2; i++) {
const ang = (i / steps2) * Math.PI * 2;
const ddx = Math.cos(ang);
const ddy = Math.sin(ang);
let maxDist = r;
for (let step = TILE * 0.5; step < r; step += TILE * 0.6) {
const lgx = Math.floor((sx + ddx * step) / TILE);
const lgy = Math.floor((sy + ddy * step) / TILE);
const blk = getBlock(lgx, lgy);
if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE * 0.3) {
maxDist = step;
break;
}
}
dists[i] = maxDist;
}
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.5, 'rgba(255,255,255,0.65)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
lightCtx.fillStyle = grad;
lightCtx.beginPath();
for (let i = 0; i <= steps2; i++) {
const idx = i % steps2;
const ang = (idx / steps2) * Math.PI * 2;
const px = cx + Math.cos(ang) * dists[idx];
const py = cy + Math.sin(ang) * 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);
}
}
lightCtx.globalCompositeOperation = 'source-over';
ctx.drawImage(lightC, 0, 0, W, H);
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.6 * flick;
const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r);
grad.addColorStop(0, `rgba(255,180,80,${0.12 * flick})`);
grad.addColorStop(0.5, `rgba(255,140,40,${0.06 * 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) {
state.hpEl.textContent = Math.max(0, Math.ceil(player.hp));
state.foodEl.textContent = Math.ceil(player.hunger);
document.getElementById('o2').textContent = Math.ceil(player.o2);
state.sxEl.textContent = Math.floor(player.x / TILE);
state.syEl.textContent = Math.floor(player.y / TILE);
state.todEl.textContent = night ? 'Ночь' : 'День';
state.worldIdEl.textContent = state.worldId;
if (state.isMultiplayer) {
document.getElementById('multiplayerStatus').style.display = 'flex';
state.playerCountEl.textContent = state.otherPlayers.size + 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);
}
// Миникарта (обновляем раз в ~4 кадра для оптимизации)
if (state.minimapOpen && Math.random() < 0.25) {
renderMinimap();
}
requestAnimationFrame(loop);
}
// === ui/market.js ===
// ==================== РЫНОК ПРЕДЛОЖЕНИЙ ====================
const marketPanel = document.getElementById('marketPanel');
const marketClose = document.getElementById('marketClose');
const marketContent = document.getElementById('marketContent');
function initMarket() {
// Кнопка закрытия
marketClose.addEventListener('click', () => closeMarket());
// Слушаем событие от save-controls.js
document.addEventListener('toggleMarket', () => toggleMarket());
}
function toggleMarket() {
if (marketPanel.style.display === 'none' || !marketPanel.style.display) {
openMarket();
} else {
closeMarket();
}
}
function closeMarket() {
marketPanel.style.display = 'none';
}
function openMarket() {
marketPanel.style.display = 'block';
renderMarket();
}
function renderMarket() {
if (!state.socket) {
marketContent.innerHTML = '<p style="color:#aaa;">Подключение к серверу...</p>';
return;
}
// Запрашиваем список ордеров с сервера
state.socket.emit('market_list', {}, (orders) => {
if (!orders || !orders.length) {
marketContent.innerHTML = `
<p style="color:#aaa;text-align:center;">Предложений пока нет</p>
<div style="margin-top:12px;">
<button id="createOrderBtn" style="width:100%;padding:10px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">
+ Выставить предмет
</button>
</div>
`;
const btn = document.getElementById('createOrderBtn');
if (btn) btn.addEventListener('click', showCreateOrder);
return;
}
let html = '<div style="margin-bottom:8px;"><button id="createOrderBtn" style="padding:8px 14px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">+ Выставить предмет</button></div>';
html += orders.map(o => {
// Determine if current player can accept this order (not their own)
const isOwnOrder = o.seller_id === state.mySocketId;
const canAccept = !isOwnOrder && state.isMultiplayer;
return `
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.1);">
<div>
<div style="display:flex;align-items:center;margin-bottom:2px;">
${BLOCKS[o.offer_item] ? `<img src="${tex[o.offer_item].toDataURL()}" width="20" height="20" style="margin-right:4px;">` : ''}
${o.offer_item} ×${o.offer_qty}
</div>
<span style="color:#888;"> ⇄ </span>
<div style="display:flex;align-items:center;">
${BLOCKS[o.want_item] ? `<img src="${tex[o.want_item].toDataURL()}" width="20" height="20" style="margin-right:4px;">` : ''}
${o.want_item} ×${o.want_qty}
</div>
</div>
<div style="text-align:right;">
<div style="font-size:11px;color:#888;margin-bottom:2px;">${o.seller || 'Аноним'}</div>
${canAccept ?
`<button class="accept-order-btn" data-order-id="${o.id}" style="padding:4px 8px;background:#28a745;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">Принять</button>` :
isOwnOrder ?
`<button class="cancel-order-btn" data-order-id="${o.id}" style="padding:4px 8px;background:#dc3545;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">Отменить</button>` :
`<span style="color:#6c757d;font-size:11px;">Ваше предложение</span>`
}
</div>
</div>
`;
}).join('');
marketContent.innerHTML = html;
// Add event listeners for accept/cancel buttons
document.querySelectorAll('.accept-order-btn').forEach(btn => {
btn.addEventListener('click', () => {
const orderId = btn.getAttribute('data-order-id');
if (state.socket) {
state.socket.emit('market_accept', { orderId: orderId });
}
});
});
document.querySelectorAll('.cancel-order-btn').forEach(btn => {
btn.addEventListener('click', () => {
const orderId = btn.getAttribute('data-order-id');
if (state.socket) {
state.socket.emit('market_cancel', { id: orderId });
}
});
});
const createBtn = document.getElementById('createOrderBtn');
if (createBtn) createBtn.addEventListener('click', showCreateOrder);
});
}
function showCreateOrder() {
const invItems = Object.keys(state.inv).filter(id => state.inv[id] > 0);
// Create item options with images for offer
const offerOptions = invItems.map(id => {
const count = state.inv[id];
let itemHTML = '';
if (BLOCKS[id]) {
itemHTML = `<img src="${tex[id].toDataURL()}" width="24" height="24" style="vertical-align:middle;margin-right:4px;">`;
} else if (ITEMS[id]) {
itemHTML = `<span style="font-size:20px;">${ITEMS[id].icon}</span>`;
} else if (TOOLS[id]) {
itemHTML = `<span style="font-size:20px;">${TOOLS[id].icon}</span>`;
} else if (id === 'iron_armor') {
itemHTML = `<span style="font-size:20px;">🛡️</span>`;
}
return `<option value="${id}" data-count="${count}">${itemHTML}${id} (×${count})</option>`;
}).join('');
marketContent.innerHTML = `
<div style="padding:12px;">
<h3 style="margin:0 0 12px;font-size:14px;text-align:center;">Выставить предмет на обмен</h3>
<div style="margin-bottom:12px;">
<label style="display:block;font-size:12px;color:#aaa;margin-bottom:4px;">Что отдаете:</label>
<select id="offerItem" style="width:100%;padding:8px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;">
${offerOptions}
</select>
<div id="offerCountContainer" style="margin-top:6px;display:flex;align-items:center;">
<label style="font-size:12px;color:#aaa;margin-right:8px;">Количество:</label>
<input type="number" id="offerCount" min="1" value="1" style="width:60px;padding:4px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;">
<span id="offerMaxCount" style="font-size:11px;color:#888;margin-left:6px;"></span>
</div>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;font-size:12px;color:#aaa;margin-bottom:4px;">Что хотите получить:</label>
<select id="wantItem" style="width:100%;padding:8px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;">
${Object.keys(BLOCKS).map(id => {
const block = BLOCKS[id];
let icon = '';
if (block.tex) {
icon = `<img src="${tex[id].toDataURL()}" width="20" height="20" style="vertical-align:middle;margin-right:4px;">`;
}
return `<option value="${id}">${icon}${block.n}</option>`;
}).join('')}
${Object.keys(ITEMS).map(id => {
const item = ITEMS[id];
return `<option value="${id}"><span style="font-size:18px;">${item.icon}</span>${item.n}</option>`;
}).join('')}
${Object.keys(TOOLS).map(id => {
const tool = TOOLS[id];
return `<option value="${id}"><span style="font-size:18px;">${tool.icon}</span>${tool.n}</option>`;
}).join('')}
<option value="iron_armor"><span style="font-size:18px;">🛡️</span>Железная броня</option>
</select>
<div id="wantCountContainer" style="margin-top:6px;display:flex;align-items:center;">
<label style="font-size:12px;color:#aaa;margin-right:8px;">Количество:</label>
<input type="number" id="wantCount" min="1" value="1" style="width:60px;padding:4px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;">
</div>
</div>
<div style="display:flex;gap:8px;">
<button id="submitOrder" style="flex:1;padding:10px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">Выставить</button>
<button id="cancelOrder" style="flex:1;padding:10px;background:#555;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">Отмена</button>
</div>
</div>
`;
// Update max count when offer item changes
const offerSelect = document.getElementById('offerItem');
const offerCountInput = document.getElementById('offerCount');
const offerMaxCountSpan = document.getElementById('offerMaxCount');
function updateOfferMax() {
const selectedId = offerSelect.value;
const maxCount = state.inv[selectedId] || 0;
offerCountInput.max = maxCount;
offerCountInput.value = Math.min(parseInt(offerCountInput.value) || 1, maxCount);
offerMaxCountSpan.textContent = `Максимум: ${maxCount}`;
// If current value exceeds max, adjust it
if (parseInt(offerCountInput.value) > maxCount) {
offerCountInput.value = maxCount;
}
}
offerSelect.addEventListener('change', updateOfferMax);
offerCountInput.addEventListener('input', () => {
const val = parseInt(offerCountInput.value) || 1;
const max = parseInt(offerSelect.value ? (state.inv[offerSelect.value] || 0) : 0);
if (val > max) offerCountInput.value = max;
});
// Initialize
if (offerSelect.options.length > 0) {
offerSelect.selectedIndex = 0;
updateOfferMax();
}
document.getElementById('cancelOrder').addEventListener('click', () => renderMarket());
document.getElementById('submitOrder').addEventListener('click', () => {
const offerItem = offerSelect.value;
const offerQty = parseInt(offerCountInput.value) || 1;
const wantItem = document.getElementById('wantItem').value;
const wantQty = parseInt(document.getElementById('wantCount').value) || 1;
if (!offerItem || !wantItem) {
alert('Выберите оба предмета');
return;
}
const offerMax = state.inv[offerItem] || 0;
if (offerQty > offerMax) {
alert(`Недостаточно предметов. Доступно: ${offerMax}`);
return;
}
if (state.socket) {
// Lock items for trade
state.socket.emit('market_lock_items', {
offer_item: offerItem,
offer_qty: offerQty,
want_item: wantItem,
want_qty: wantQty
}, (result) => {
if (result.success) {
// Items locked, now create the offer
state.socket.emit('market_create', {
offer_item: offerItem,
offer_qty: offerQty,
want_item: wantItem,
want_qty: wantQty
});
playSound('click');
setTimeout(renderMarket, 500);
} else {
alert(result.message || 'Не удалось заблокировать предметы для обмена');
}
});
}
});
}
// === ui/respawn.js ===
// ==================== RESPAWN ====================
function initRespawn() {
document.getElementById('respawnBtn').onclick = async () => {
playSound('click'); // Звук клика по кнопке
console.log('=== RESPAWN CLICKED ===');
console.log('isMultiplayer:', state.isMultiplayer);
console.log('otherPlayers.size:', state.otherPlayers.size);
console.log('player.hp before respawn:', state.player.hp);
const player = state.player;
const deathEl = state.deathEl;
// В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
if (state.isMultiplayer && state.otherPlayers.size > 0) {
console.log('Мультиплеер режим - возрождение в начальной точке');
player.hp = 100;
player.hunger = 100;
player.o2 = 100;
player.vx = player.vy = 0;
player.invuln = 0;
player.x = state.spawnPoint.x;
player.y = state.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 = state.spawnPoint.x;
player.y = state.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 ===');
};
}
// === ui/share.js ===
// ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
function initShare() {
// Обработчик клика на worldId для копирования ссылки
document.getElementById('worldId').onclick = () => {
const shareUrl = new URL(window.location.href);
shareUrl.searchParams.set('world', state.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);
}
};
}
function shareWorld() {
const shareUrl = new URL(window.location.href);
shareUrl.searchParams.set('world', state.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);
}
}
// === input/controls.js ===
// ==================== УПРАВЛЕНИЕ ====================
const inp = state.inp;
function bindHold(el, key) {
const down = (e) => { e.preventDefault(); state.inp[key] = true; };
const up = (e) => { e.preventDefault(); state.inp[key] = false; };
el.addEventListener('pointerdown', down);
el.addEventListener('pointerup', up);
el.addEventListener('pointerleave', up);
}
function initControls() {
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') state.inp.l = true;
if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = true;
if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = true;
if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = true;
});
window.addEventListener('keyup', (e) => {
if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = false;
if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = false;
if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = false;
if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = false;
});
}
// === multiplayer/voice-chat.js ===
// Голосовой чат
let voiceSocket = null;
let voiceStream = null;
let audioCtx = null;
let voiceProcessor = null;
let voiceActive = false;
let voiceMode = 'near'; // 'near' — 600px radius, 'world' — global
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;
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, sampleRate: 24000 } });
audioCtx = new AudioContext({ sampleRate: 24000 });
const source = audioCtx.createMediaStreamSource(voiceStream);
voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
voiceProcessor.onaudioprocess = (e) => {
if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
const pcm = e.inputBuffer.getChannelData(0);
// Конвертируем float32 → int16 для экономии трафика
const int16 = new Int16Array(pcm.length);
for (let i = 0; i < pcm.length; i++) {
const s = Math.max(-1, Math.min(1, pcm[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
voiceSocket.emit('voice_data', int16.buffer);
};
source.connect(voiceProcessor);
voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя
// Подключаемся к голосовому серверу
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
voiceSocket.on('connect', () => {
voiceSocket.emit('voice_join', { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок', mode: voiceMode });
});
voiceSocket.on('voice_in', (payload) => {
// Воспроизводим входящий голос
const { data, meta, volume } = payload;
if (!audioCtx || audioCtx.state === 'closed') return;
// Int16 → Float32
const int16 = new Int16Array(data);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume;
}
const buf = audioCtx.createBuffer(1, float32.length, 24000);
buf.getChannelData(0).set(float32);
const src = audioCtx.createBufferSource();
src.buffer = buf;
const gain = audioCtx.createGain();
gain.gain.value = volume;
src.connect(gain).connect(audioCtx.destination);
src.start();
// Индикатор
speakingIndicator.style.display = 'block';
speakingIndicator.textContent = `🔊 ${meta.name}`;
clearTimeout(speakingTimeout);
speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500);
});
voiceActive = true;
voiceBtn.textContent = '🎤';
voiceBtn.style.background = '#2ecc71';
} catch (e) {
console.error('Voice error:', e);
voiceBtn.style.background = '#e74c3c';
}
};
function initVoice() {
// Voice position update hook — should be called from main loop
let voicePosT = 0;
return {
update(dt) {
voicePosT += dt;
if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) {
voicePosT = 0;
voiceSocket.emit('voice_pos', { x: state.player.x, y: state.player.y });
}
}
};
}
// === ui/inventory.js ===
// Inventory UI
function renderInventory() {
const inventoryGrid = state.inventoryGrid;
const inv = state.inv;
const selected = state.selected;
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'); // Звук клика по инвентарю
state.selected = id;
// Обновляем список последних предметов
state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть
state.recentItems.unshift(id); // Добавляем в начало
state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5
rebuildHotbar();
renderInventory();
};
// Двойной клик для надевания брони
slot.ondblclick = () => {
if (id === 'iron_armor' && inv.iron_armor > 0) {
// Если уже надета броня - снимаем её
if (state.player.equippedArmor === 'iron_armor') {
state.player.equippedArmor = null;
state.player.armor = 0;
console.log('[ARMOR] Iron armor unequipped');
} else {
// Надеваем броню
state.player.equippedArmor = 'iron_armor';
state.player.armor = BLOCKS['iron_armor'].armor;
console.log('[ARMOR] Iron armor equipped - armor:', state.player.armor);
}
playSound('click');
renderInventory();
}
};
}
inventoryGrid.appendChild(slot);
}
}
// === ui/craft.js ===
// Craft UI
function canCraft(r) {
const inv = state.inv;
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() {
const inv = state.inv;
const recipesEl = state.recipesEl;
recipesEl.innerHTML = '';
for (const r of RECIPES) {
const row = document.createElement('div');
row.className = 'recipe';
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}`;
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(' ');
info.appendChild(nm); info.appendChild(cs);
const btn = document.createElement('button');
btn.className = 'rcraft';
btn.textContent = 'Создать';
btn.disabled = !canCraft(r);
btn.onclick = () => {
if (!canCraft(r)) return;
playSound('click');
for (const res in r.cost) inv[res] -= r.cost[res];
inv[r.out] = (inv[r.out] || 0) + r.qty;
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;
function toggleCraft() {
playSound('click'); // Звук клика по кнопке
craftOpen = !craftOpen;
state.craftPanel.style.display = craftOpen ? 'block' : 'none';
if (craftOpen) {
renderCraft();
// Закрываем инвентарь если открыт крафт
inventoryOpen = false;
state.inventoryPanel.style.display = 'none';
}
}
function closeCraft() {
playSound('click'); // Звук клика по кнопке
craftOpen = false;
state.craftPanel.style.display = 'none';
}
function toggleInventory() {
playSound('click'); // Звук клика по кнопке
inventoryOpen = true;
state.inventoryPanel.style.display = 'block';
renderInventory();
// Закрываем крафт если открыт инвентарь
craftOpen = false;
state.craftPanel.style.display = 'none';
}
function closeInventory() {
playSound('click'); // Звук клика по кнопке
inventoryOpen = false;
state.inventoryPanel.style.display = 'none';
}
// === main.js ===
// ==================== ENTRY POINT ====================
// ==================== 1) CONFIG — parse URL ====================
const urlParams = new URLSearchParams(window.location.search);
state.SERVER_URL = SERVER_URL;
state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot';
state.TELEGRAM_APP_SHORT_NAME = 'minegrechka';
// Set TG-related state
if (isTgWebApp) {
state.myTgId = tgUser?.id || null;
}
state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
// ==================== 2) START MENU ====================
// Show the start menu before initializing the game.
// The menu returns a Promise that resolves with the worldId.
async function startGame() {
const worldId = await showStartMenu();
// Set worldId and playerName
state.worldId = worldId;
if (!state.playerName) {
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
localStorage.setItem('minegrechka_playerName', state.playerName);
}
console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp);
// Validate TG initData if in TG context
if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) {
try {
const resp = await fetch(state.SERVER_URL + '/api/tg/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: Telegram.WebApp.initData })
});
const data = await resp.json();
if (data.ok && data.tg_id) {
console.log('[TG] Authenticated, tg_id:', data.tg_id);
state.myTgId = data.tg_id;
}
} catch (e) { console.warn('[TG] Auth failed:', e); }
}
// Update URL with world code
try {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('world', state.worldId);
if (typeof history !== 'undefined' && history.replaceState) {
history.replaceState(null, '', newUrl.toString());
}
} catch (e) {}
// ==================== 3) CANVAS — init from canvas module ====================
initCanvas();
// ==================== 4) STATE — set DOM refs ====================
state.hpEl = document.getElementById('hp');
state.foodEl = document.getElementById('food');
state.sxEl = document.getElementById('sx');
state.syEl = document.getElementById('sy');
state.todEl = document.getElementById('tod');
state.worldIdEl = document.getElementById('worldId');
state.playerCountEl = document.getElementById('playerCount');
state.deathEl = document.getElementById('death');
state.hotbarEl = document.getElementById('hotbar');
state.craftPanel = document.getElementById('craftPanel');
state.recipesEl = document.getElementById('recipes');
state.inventoryPanel = document.getElementById('inventoryPanel');
state.inventoryGrid = document.getElementById('inventoryGrid');
state.marketPanel = document.getElementById('marketPanel');
state.marketContent = document.getElementById('marketContent');
state.tradePanel = document.getElementById('tradePanel');
state.tradeContent = document.getElementById('tradeContent');
state.multiplayerStatus = document.getElementById('multiplayerStatus');
// ==================== 5) TEXTURES — initTextures() ====================
initTextures();
state.tex = tex;
state.itemTex = itemTex;
// ==================== 6) CRAFT/INVENTORY BUTTONS ====================
const craftBtn = document.getElementById('craftBtn');
const invToggle = document.getElementById('invToggle');
const craftClose = document.getElementById('craftClose');
const inventoryClose = document.getElementById('inventoryClose');
if (craftBtn) craftBtn.onclick = () => toggleCraft();
if (craftClose) craftClose.onclick = () => closeCraft();
if (invToggle) invToggle.onclick = () => toggleInventory();
if (inventoryClose) inventoryClose.onclick = () => closeInventory();
// ==================== 7) CONTROLS — initControls ====================
initControls();
// ==================== 8) MOUSE — initMouseHandlers ====================
initMouseHandlers();
// ==================== 9) UI MODULES — init all UI ====================
initChat();
initFurnace();
initMinimap();
initSaveControls();
initMarket();
initRespawn();
initShare();
initModes();
// ==================== 10) SOCKET — initSocket ====================
initSocket();
// ==================== 11) VOICE — initVoice ====================
initVoice();
// ==================== 12) SAVE — loadGame, applySave ====================
rebuildHotbar();
initDB().then(async () => {
const loadedSave = await loadGame();
if (loadedSave) {
await applySave(loadedSave);
console.log('Загружено сохранение, HP:', state.player.hp);
if (state.player.hp <= 0) {
console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
state.player.hp = 100;
state.player.hunger = 100;
state.player.o2 = 100;
state.player.x = state.spawnPoint.x;
state.player.y = state.spawnPoint.y;
state.player.vx = state.player.vy = 0;
state.player.invuln = 0;
state.player.fallStartY = state.player.y;
}
} else {
console.log('Сохранение не найдено, начинаем новую игру');
state.player.hp = 100;
state.player.hunger = 100;
state.player.o2 = 100;
state.player.vx = state.player.vy = 0;
state.player.invuln = 0;
// старт — на поверхности
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 = state.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;
}
}
state.player.y = safeGY * state.TILE;
state.player.x = startGX * state.TILE;
state.player.fallStartY = state.player.y;
state.spawnPoint.x = state.player.x;
state.spawnPoint.y = state.player.y;
console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.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);
state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
state.player.fallStartY = state.player.y;
for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
genColumn(gx);
}
});
// ==================== 13) LOOP — startLoop ====================
requestAnimationFrame(loop);
}
// Start the game
startGame().catch(err => {
console.error('Fatal error starting game:', err);
});
})();