grechka-game/game.js

6999 lines
242 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// GrechkaCraft — auto-bundled from ES modules
(function() {
"use strict";
// Touch device detection & PWA service worker
(function() {
// Touch device classes
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
document.body.classList.add('touch-device');
}
// Orientation handler
function updateOrientation() {
if (window.innerHeight > window.innerWidth) {
document.body.classList.remove('landscape');
document.body.classList.add('portrait');
} else {
document.body.classList.remove('portrait');
document.body.classList.add('landscape');
}
}
window.addEventListener('resize', updateOrientation);
window.addEventListener('orientationchange', function() {
setTimeout(updateOrientation, 100);
});
updateOrientation();
// PWA: service worker for fullscreen install
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function(err) {
console.warn('[SW] Registration failed:', err);
});
}
// Fullscreen: hide browser chrome on mobile
function goFullscreen() {
if (window.Telegram?.WebApp) {
Telegram.WebApp.expand();
try { Telegram.WebApp.requestFullscreen(); } catch(e) {}
return;
}
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
var el = document.documentElement;
if (el.requestFullscreen) el.requestFullscreen().catch(function(){});
else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
}
// Scroll to hide address bar on iOS/Android
window.scrollTo(0, 0);
}
// Try on first interaction
document.addEventListener('click', goFullscreen, { once: true });
document.addEventListener('touchstart', goFullscreen, { once: true });
// Retry on orientation change (browser chrome reappears on rotate)
window.addEventListener('orientationchange', function() {
setTimeout(goFullscreen, 300);
});
// Also on resize (mobile address bar hides/shows)
window.addEventListener('resize', function() {
if (window.innerHeight > window.innerWidth) return; // portrait — fine
setTimeout(function() {
window.scrollTo(0, 0);
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
goFullscreen();
}
}, 200);
});
// Lock scroll to prevent pull-to-refresh
document.addEventListener('touchmove', function(e) {
if (e.target === document.documentElement || e.target === document.body) {
e.preventDefault();
}
}, { passive: false });
// TG Mini App: expand to full screen
if (window.Telegram?.WebApp) {
Telegram.WebApp.expand();
if (Telegram.WebApp.requestFullscreen) {
Telegram.WebApp.requestFullscreen();
}
}
})();
// === 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 = {
// Константы (для доступа из модулей)
TILE,
SEA_GY,
BEDROCK_GY,
GEN_MARGIN_X,
GRAV,
GRAV_WATER,
MOVE,
JUMP,
DAY_LEN,
SAVE_KEY,
MOVE_SEND_INTERVAL,
MAX_RAINDROPS,
MAX_CHAT_MESSAGES,
VOICE_SERVER,
// Камера
camX: 0,
camY: 0,
W: 0,
H: 0,
dpr: 1,
// Canvas/DOM (заполняется в main.js)
gameEl: null,
canvas: null,
ctx: null,
lightC: null,
lightCtx: null,
// День/ночь
worldTime: 0,
isNightTime: false,
dayNightCycle: true,
// Мультиплеер
isMultiplayer: false,
mySocketId: null,
socket: null,
voiceSocket: null,
voicePosT: 0,
// Сервер
SERVER_URL: '',
TELEGRAM_BOT_USERNAME: '',
TELEGRAM_APP_SHORT_NAME: '',
// Игрок
playerName: null,
worldId: null,
// Инвентарь/UI
selected: "dirt",
showFullInventory: false,
craftOpen: false,
inventoryOpen: false,
chatOpen: false,
minimapOpen: false,
modeIdx: 0,
currentFurnaceKey: null,
// Мир
worldSeed: Math.floor(Math.random() * 1000000),
// Погода
weatherType: "clear",
isRaining: false,
rainIntensity: 0,
weatherTimer: 0,
weatherChangeInterval: 60 + Math.random() * 120,
snowflakes: [],
lightningFlash: 0,
lightningTimer: 0,
// Мобы/спавн
spawnT: 0,
// Цикл
last: 0,
prevJump: false,
// Сеть — throttle отправки позиции
lastMoveSendTime: 0,
lastSentX: 0,
lastSentY: 0,
// Mining progress
mining: { gx: null, gy: null, progress: 0, hardness: 0 },
// Trade/Market
myTgId: null,
tradePanel: null,
tradeContent: null,
marketPanel: null,
marketContent: null,
// DOM-ссылки (заполняются в main.js)
hpEl: null,
foodEl: null,
sxEl: null,
syEl: null,
todEl: null,
worldIdEl: null,
playerCountEl: null,
deathEl: null,
hotbarEl: null,
craftPanel: null,
recipesEl: null,
inventoryPanel: null,
inventoryGrid: null,
multiplayerStatus: null,
tempEl: null,
// Игрок
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,
temperature: 15,
slowTimer: 0
},
// Точка спавна
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,
wood_axe: 0, stone_axe: 0, iron_axe: 0,
wood_shovel: 0, stone_shovel: 0, iron_shovel: 0,
iron_armor: 0, gold_armor: 0,
bow: 0, furnace: 0,
bed: 0, boat: 0,
iron_ingot: 0, gold_ingot: 0, copper_ingot: 0,
cobblestone: 0,
// New items
chicken_meat: 0, feather: 0, bone: 0, gunpowder: 0,
wheat: 0, bread: 0, carrot: 0, potato: 0, baked_potato: 0,
mushroom_stew: 0,
scorpion_stinger: 0, polar_fur: 0, slime_ball: 0, eagle_feather: 0,
snow: 0, ice: 0, cactus: 0, mushroom: 0, moss: 0, farmland: 0,
spruce_leaves: 0, dead_bush: 0, swamp_water: 0,
wheat_seeds: 0, wheat_stage0: 0, carrot_stage0: 0, potato_stage0: 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, l: false, r: false, j: false, s: 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(),
protections: new Map(),
// Последние выбранные предметы
recentItems: [],
// Активные печи
activeFurnaces: new Map(),
cropTimers: new Map(),
// Сгенерированные колонны
generated: new Set(),
// Изображение героя
heroImg: new Image(),
// Сетка мира
grid: new Map(),
blocks: [],
// In-memory save fallback
inMemorySave: null,
};
// === config.js ===
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
// This module only sets SERVER_URL. Player name and world ID are handled in main.js
// to avoid duplicate prompts.
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. Это может вызвать проблемы.');
}
// === 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;max-height:90vh;display:flex;flex-direction:column; }
.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;max-height:80vh;overflow-y:auto;-webkit-overflow-scrolling:touch; }
.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 hideBoot(){var b=document.getElementById("boot");if(b){b.remove();}}
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.
// `default` is NOT a playable world: the server returns blocks:[] for it,
// so auto-joining leaves the player staring at empty sky with no menu to
// escape to. Treat it as "no world chosen" and show the menu instead.
const RESERVED_WORLD_IDS = new Set(['', 'default', 'lobby', 'null', 'undefined']);
if ((worldParam && worldParam.trim() !== '' && !RESERVED_WORLD_IDS.has(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);
}
hideBoot();
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 — always visible, with Play button
{
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 nameRow = document.createElement('div');
nameRow.style.cssText = 'display:flex;gap:6px;';
const nameInput = document.createElement('input');
nameInput.className = 'mc-input';
nameInput.style.flex = '1';
nameInput.type = 'text';
nameInput.placeholder = 'Игрок';
nameInput.value = state.playerName || localStorage.getItem('minegrechka_playerName') || (tgUser ? (tgUser.username || tgUser.first_name || '') : '');
nameInput.maxLength = 20;
nameRow.appendChild(nameInput);
const playBtn = document.createElement('button');
playBtn.className = 'mc-btn mc-btn-green';
playBtn.style.cssText = 'width:auto;margin-bottom:0;padding:10px 20px;white-space:nowrap;';
playBtn.textContent = '▶ Играть';
playBtn.onclick = () => {
const name = nameInput.value.trim() || 'Игрок';
state.playerName = name;
localStorage.setItem('minegrechka_playerName', name);
// If no worldId yet, generate one and start
if (!selectedWorldId && !state.worldId) {
selectedWorldId = generateShortWorldCode();
}
overlay.remove();
styleEl.remove();
resolve(selectedWorldId || state.worldId);
};
nameRow.appendChild(playBtn);
body.appendChild(nameRow);
// Allow Enter key to start game
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') playBtn.click();
});
// Auto-focus input on mobile
setTimeout(() => nameInput.focus(), 100);
}
box.appendChild(body);
overlay.appendChild(box);
document.body.appendChild(overlay);
hideBoot();
// Set default player name if still empty
if (!state.playerName) {
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
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 ===
// ==================== ХОЛСТ И РАЗМЕРЫ ====================
function initCanvas() {
const gameEl = document.getElementById('game');
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
// offscreen light map
const lightC = document.createElement('canvas');
const lightCtx = lightC.getContext('2d');
const dpr = Math.max(1, window.devicePixelRatio || 1);
function resize() {
state.W = gameEl.clientWidth;
state.H = gameEl.clientHeight;
canvas.width = state.W * dpr;
canvas.height = state.H * dpr;
lightC.width = state.W * dpr;
lightC.height = state.H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
// Store everything on state
state.gameEl = gameEl;
state.canvas = canvas;
state.ctx = ctx;
state.lightC = lightC;
state.lightCtx = lightCtx;
state.dpr = dpr;
}
// Convenience exports for modules that use canvas directly
function getCanvas() { return state.canvas; }
function getCtx() { return state.ctx; }
function getLightC() { return state.lightC; }
function getLightCtx() { return state.lightCtx; }
// === data/blocks.js ===
const BLOCKS = {
air: { n:'Воздух', solid:false, hardness:0 },
grass: { n:'Трава', c:'#7cfc00', solid:true, hardness:3, toolType:'shovel' },
dirt: { n:'Грязь', c:'#8b4513', solid:true, hardness:3, toolType:'shovel' },
stone: { n:'Камень', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' },
sand: { n:'Песок', c:'#f4d06f', solid:true, hardness:2, toolType:'shovel' },
gravel: { n:'Гравий', c:'#95a5a6', solid:true, hardness:3, toolType:'shovel' },
clay: { n:'Глина', c:'#74b9ff', solid:true, hardness:3, toolType:'shovel' },
wood: { n:'Дерево', c:'#d35400', solid:true, hardness:4, toolType:'axe' },
planks: { n:'Доски', c:'#e67e22', solid:true, hardness:3, toolType:'axe' },
ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true, hardness:1, toolType:null },
leaves: { n:'Листва', c:'#2ecc71', solid:true, hardness:1, toolType:'axe', minTool:null },
glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55, hardness:1, toolType:null },
water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true, hardness:0 },
coal: { n:'Уголь', c:'#2c3e50', solid:true, hardness:8, toolType:'pickaxe', minTool:'wood' },
copper_ore:{ n:'Медь', c:'#e17055', solid:true, hardness:10, toolType:'pickaxe', minTool:'wood' },
iron_ore: { n:'Железо', c:'#dcdde1', solid:true, hardness:12, toolType:'pickaxe', minTool:'stone' },
iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5, hardness:0 },
gold_ore: { n:'Золото', c:'#f1c40f', solid:true, hardness:15, toolType:'pickaxe', minTool:'stone' },
diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true, hardness:20, toolType:'pickaxe', minTool:'iron' },
brick: { n:'Кирпич', c:'#c0392b', solid:true, hardness:8, toolType:'pickaxe' },
tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true, hardness:1, toolType:null },
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280, hardness:2, toolType:null },
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220, hardness:1, toolType:null },
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true, hardness:Infinity },
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true, hardness:0 },
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true, hardness:2, toolType:'axe' },
boat: { n:'Лодка', c:'#8B4513', solid:false, hardness:0 },
furnace: { n:'Печь', c:'#696969', solid:true, smelting:true, hardness:8, toolType:'pickaxe', minTool:'wood' },
cobblestone: { n:'Булыжник', c:'#7f8c8d', solid:true, hardness:6, toolType:'pickaxe' },
flag: { n:'Флаг', c:'#e74c3c', solid:true, hardness:1, toolType:'axe', protectionRadius:10 },
// === BIOME BLOCKS ===
snow: { n:'Снег', c:'#ecf0f1', solid:true, hardness:1, toolType:'shovel' },
ice: { n:'Лёд', c:'#74b9ff', solid:true, hardness:1, slip:true, toolType:'pickaxe' },
cactus: { n:'Кактус', c:'#27ae60', solid:true, hardness:1, hurt:true, toolType:null },
mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true, hardness:0 },
moss: { n:'Мох', c:'#0a6640', solid:true, hardness:1, toolType:null },
swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true, hardness:0 },
farmland: { n:'Грядка', c:'#8B6914', solid:true, hardness:1, farmable:true, toolType:'shovel' },
dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true, hardness:0 },
spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true, hardness:1, toolType:'axe' },
// === CROP STAGES ===
wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'wheat_stage1' },
wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'wheat_stage2' },
wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'wheat_stage3' },
wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'wheat', harvestQty:2 },
carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'carrot_stage1' },
carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'carrot_stage2' },
carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'carrot_stage3' },
carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'carrot', harvestQty:3 },
potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, hardness:0, growthStage:0, growthTime:30, growsTo:'potato_stage1' },
potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, hardness:0, growthStage:1, growthTime:30, growsTo:'potato_stage2' },
potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, hardness:0, growthStage:2, growthTime:30, growsTo:'potato_stage3' },
potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, hardness:0, harvestable:true, harvestItem:'potato', harvestQty:2 },
};
// === world/world-storage.js ===
// Мир-хранилище — используем state.grid и state.blocks для единого источника данных
// Это гарантирует, что все модули (включая loop.js) видят одни и те же данные
const grid = state.grid; // Map: key "gx,gy" => {gx,gy,t, ...}
const blocks = state.blocks; // Array для рендера/перебора видимых
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
// ========== BIOME SYSTEM ==========
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);
if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8);
return biomeCache[chunk];
}
// ========== STRUCTURES ==========
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']
];
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) {
const reduction = state.player.armor;
const actualDamage = baseDamage * (1 - reduction);
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,
equippedArmor: null,
temperature: 15,
slowTimer: 0
};
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 },
chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 },
feather: { n:'Перо', icon:'🪶', stack:64 },
bone: { n:'Кость', icon:'🦴', stack:64 },
gunpowder: { n:'Порох', icon:'💥', stack:64 },
// === FARMING ITEMS ===
wheat: { n:'Пшеница', icon:'🌾', stack:64 },
bread: { n:'Хлеб', icon:'🍞', food:30 },
carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 },
potato: { n:'Картофель', icon:'🥔', stack:64 },
baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 },
wheat_seeds: { n:'Семена пшеницы', icon:'🌱', stack:64 },
mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 },
// === MOB DROP ITEMS ===
scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 },
polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 },
slime_ball: { n:'Слизь', icon:'🟢', stack:64 },
eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 },
// === ARMOR ===
gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 },
};
// Runtime extensions — предметы от обжига
ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
// Axes and shovels
ITEMS.wood_axe = { n:'Деревянный топор', icon:'🪓' };
ITEMS.stone_axe = { n:'Каменный топор', icon:'🪓' };
ITEMS.iron_axe = { n:'Железный топор', icon:'🪓' };
ITEMS.wood_shovel = { n:'Деревянная лопата', icon:'🔱' };
ITEMS.stone_shovel = { n:'Каменная лопата', icon:'🔱' };
ITEMS.iron_shovel = { n:'Железная лопата', icon:'🔱' };
// === data/tools.js ===
const TOOLS = {
wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', damage:1, durability:60, toolType:'pickaxe', toolTier:'wood', speed:2 },
stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', damage:2, durability:132, toolType:'pickaxe', toolTier:'stone', speed:4 },
iron_pickaxe: { n:'Железная кирка', icon:'⛏️', damage:3, durability:250, toolType:'pickaxe', toolTier:'iron', speed:6 },
wood_sword: { n:'Деревянный меч', icon:'🗡️', damage:4, durability:60 },
stone_sword: { n:'Каменный меч', icon:'🗡️', damage:5, durability:132 },
iron_sword: { n:'Железный меч', icon:'🗡️', damage:7, durability:250 },
wood_axe: { n:'Деревянный топор', icon:'🪓', damage:3, durability:60, toolType:'axe', toolTier:'wood', speed:2 },
stone_axe: { n:'Каменный топор', icon:'🪓', damage:4, durability:132, toolType:'axe', toolTier:'stone', speed:4 },
iron_axe: { n:'Железный топор', icon:'🪓', damage:5, durability:250, toolType:'axe', toolTier:'iron', speed:6 },
wood_shovel: { n:'Деревянная лопата', icon:'🔱', damage:1, durability:60, toolType:'shovel', toolTier:'wood', speed:2 },
stone_shovel: { n:'Каменная лопата', icon:'🔱', damage:2, durability:132, toolType:'shovel', toolTier:'stone', speed:4 },
iron_shovel: { n:'Железная лопата', icon:'🔱', damage:3, durability:250, toolType:'shovel', toolTier:'iron', speed:6 },
bow: { n:'Лук', icon:'🏹', damage:10, durability:200 },
};
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
}
function getBestTool(blockType) {
const block = BLOCKS[blockType];
if (!block || !block.toolType) return null;
const toolType = block.toolType;
for (const tier of ['iron', 'stone', 'wood']) {
const toolId = `${tier}_${toolType}`;
if (TOOLS[toolId] && state.inv[toolId] > 0) return toolId;
}
return null;
}
function toolTierLessThan(toolTier, minTier) {
const tiers = { wood: 1, stone: 2, iron: 3 };
return (tiers[toolTier] || 0) < (tiers[minTier] || 0);
}
function getMiningSpeed(blockType) {
const block = BLOCKS[blockType];
if (!block) return 1;
const toolId = getBestTool(blockType);
if (toolId) {
const tool = TOOLS[toolId];
if (tool.toolType === block.toolType) return tool.speed;
}
return 1; // Bare hands = 1 click per hit
}
// === render/textures.js ===
// Текстуры блоков (простые)
const tex = {};
// --- Item/tool sprite generator ---\n
const itemTex = {};
function drawArrow(ctx, color) {
ctx.fillStyle = '#8B4513';
ctx.fillRect(8, 14, 16, 3);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(24, 15);
ctx.lineTo(18, 10);
ctx.lineTo(18, 20);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#e74c3c';
ctx.beginPath();
ctx.moveTo(8, 15);
ctx.lineTo(4, 10);
ctx.lineTo(10, 14);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(8, 16);
ctx.lineTo(4, 21);
ctx.lineTo(10, 17);
ctx.closePath();
ctx.fill();
}
function drawPickaxe(ctx, headColor, handleColor) {
ctx.fillStyle = handleColor || '#8B4513';
ctx.save();
ctx.translate(16, 16);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-2, 0, 4, 18);
ctx.fillStyle = headColor;
ctx.fillRect(-8, -3, 16, 5);
ctx.fillRect(-8, -3, 3, 8);
ctx.fillRect(5, -3, 3, 8);
ctx.restore();
}
function drawAxe(ctx, headColor, handleColor) {
ctx.fillStyle = handleColor || '#8B4513';
ctx.save();
ctx.translate(16, 16);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-2, 0, 4, 18);
ctx.fillStyle = headColor;
ctx.fillRect(-2, -8, 10, 8);
ctx.fillRect(-2, -8, 4, 12);
ctx.restore();
}
function drawShovel(ctx, headColor, handleColor) {
ctx.fillStyle = handleColor || '#8B4513';
ctx.save();
ctx.translate(16, 16);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-2, 0, 4, 18);
ctx.fillStyle = headColor;
ctx.fillRect(-6, -8, 12, 8);
ctx.fillRect(-4, -12, 8, 4);
ctx.restore();
}
function drawSword(ctx, bladeColor, guardColor) {
ctx.fillStyle = '#8B4513';
ctx.fillRect(14, 18, 4, 10);
ctx.fillStyle = guardColor || '#f1c40f';
ctx.fillRect(10, 16, 12, 3);
ctx.fillStyle = bladeColor;
ctx.beginPath();
ctx.moveTo(16, 2);
ctx.lineTo(12, 16);
ctx.lineTo(20, 16);
ctx.closePath();
ctx.fill();
}
function drawBow(ctx) {
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(12, 16, 12, -Math.PI * 0.7, Math.PI * 0.7);
ctx.stroke();
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(12, 4);
ctx.lineTo(12, 28);
ctx.stroke();
}
function drawIngot(ctx, color) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(6, 12);
ctx.lineTo(26, 12);
ctx.lineTo(24, 24);
ctx.lineTo(8, 24);
ctx.closePath();
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillRect(8, 8, 16, 4);
}
function drawMeat(ctx, cooked) {
ctx.fillStyle = cooked ? '#8B4513' : '#e74c3c';
ctx.beginPath();
ctx.ellipse(16, 18, 10, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#f5f5dc';
ctx.fillRect(8, 6, 3, 12);
ctx.fillRect(14, 4, 3, 8);
}
function makeItemTex(id) {
const c = document.createElement('canvas');
c.width = 32; c.height = 32;
const g = c.getContext('2d');
const tierColors = {
wood: { head: '#a0522d', blade: '#a0522d' },
stone: { head: '#7f8c8d', blade: '#95a5a6' },
iron: { head: '#bdc3c7', blade: '#ecf0f1' },
};
switch (id) {
case 'arrow':
drawArrow(g, '#7f8c8d');
break;
case 'bow':
drawBow(g);
break;
case 'wood_pickaxe':
drawPickaxe(g, tierColors.wood.head, tierColors.wood.blade);
break;
case 'stone_pickaxe':
drawPickaxe(g, tierColors.stone.head);
break;
case 'iron_pickaxe':
drawPickaxe(g, tierColors.iron.head);
break;
case 'wood_axe':
drawAxe(g, tierColors.wood.head);
break;
case 'stone_axe':
drawAxe(g, tierColors.stone.head);
break;
case 'iron_axe':
drawAxe(g, tierColors.iron.head);
break;
case 'wood_shovel':
drawShovel(g, tierColors.wood.head);
break;
case 'stone_shovel':
drawShovel(g, tierColors.stone.head);
break;
case 'iron_shovel':
drawShovel(g, tierColors.iron.head);
break;
case 'wood_sword':
drawSword(g, tierColors.wood.blade, '#a0522d');
break;
case 'stone_sword':
drawSword(g, tierColors.stone.blade, '#7f8c8d');
break;
case 'iron_sword':
drawSword(g, tierColors.iron.blade, '#bdc3c7');
break;
case 'iron_ingot':
drawIngot(g, '#bdc3c7');
break;
case 'gold_ingot':
drawIngot(g, '#f1c40f');
break;
case 'copper_ingot':
drawIngot(g, '#e67e22');
break;
case 'meat':
drawMeat(g, false);
break;
case 'cooked':
drawMeat(g, true);
break;
case 'wheat':
g.fillStyle = '#f1c40f';
g.fillRect(12, 8, 3, 18);
g.fillRect(8, 6, 10, 4);
break;
case 'bread':
g.fillStyle = '#d4a030';
g.fillRect(6, 12, 20, 12);
g.fillStyle = '#c08820';
g.fillRect(6, 12, 20, 3);
break;
case 'carrot':
g.fillStyle = '#e67e22';
g.fillRect(14, 10, 4, 18);
g.fillStyle = '#27ae60';
g.fillRect(12, 6, 8, 5);
break;
case 'potato':
g.fillStyle = '#d4a050';
g.beginPath();
g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2);
g.fill();
g.fillStyle = '#b88a40';
g.fillRect(10, 14, 2, 1);
g.fillRect(20, 16, 2, 1);
break;
case 'baked_potato':
g.fillStyle = '#c08820';
g.beginPath();
g.ellipse(16, 16, 8, 6, 0, 0, Math.PI * 2);
g.fill();
g.fillStyle = '#f5e6cc';
g.fillRect(12, 12, 8, 6);
break;
case 'mushroom_stew':
g.fillStyle = '#8B4513';
g.fillRect(10, 16, 12, 12);
g.fillStyle = '#e74c3c';
g.beginPath();
g.ellipse(16, 14, 8, 5, 0, Math.PI, 0);
g.fill();
break;
case 'scorpion_stinger':
g.fillStyle = '#d35400';
g.beginPath();
g.moveTo(16, 4);
g.lineTo(10, 28);
g.lineTo(22, 28);
g.closePath();
g.fill();
break;
case 'polar_fur':
g.fillStyle = '#ecf0f1';
g.fillRect(6, 6, 20, 20);
g.fillStyle = '#bdc3c7';
g.fillRect(12, 12, 8, 8);
break;
case 'slime_ball':
g.fillStyle = 'rgba(46,204,113,0.8)';
g.beginPath();
g.arc(16, 16, 10, 0, Math.PI * 2);
g.fill();
g.fillStyle = '#fff';
g.fillRect(10, 10, 4, 4);
g.fillRect(18, 10, 4, 4);
break;
case 'eagle_feather':
g.fillStyle = '#8B4513';
g.fillRect(14, 4, 4, 24);
g.fillStyle = '#ecf0f1';
g.fillRect(10, 6, 12, 10);
break;
case 'chicken_meat':
drawMeat(g, false);
g.fillStyle = '#fff';
g.fillRect(12, 8, 4, 4);
break;
case 'feather':
g.fillStyle = '#ecf0f1';
g.fillRect(14, 6, 4, 20);
g.fillStyle = '#bbb';
g.fillRect(12, 8, 8, 8);
break;
case 'bone':
g.fillStyle = '#f5f5dc';
g.fillRect(12, 10, 8, 14);
g.fillRect(8, 8, 4, 4);
g.fillRect(20, 8, 4, 4);
g.fillRect(8, 22, 4, 4);
g.fillRect(20, 22, 4, 4);
break;
case 'gunpowder':
g.fillStyle = '#555';
g.fillRect(8, 10, 6, 6);
g.fillRect(18, 14, 6, 6);
g.fillRect(12, 20, 6, 6);
break;
case 'gold_armor':
g.fillStyle = '#f1c40f';
g.fillRect(6, 6, 20, 20);
g.fillStyle = '#f39c12';
g.fillRect(10, 4, 12, 6);
g.fillRect(4, 10, 6, 16);
g.fillRect(22, 10, 6, 16);
break;
case 'wheat_seeds':
g.fillStyle = '#7dcea0';
g.fillRect(14, 16, 4, 10);
g.fillStyle = '#a8e6a0';
g.fillRect(10, 8, 4, 4);
g.fillRect(18, 6, 4, 4);
g.fillRect(14, 4, 4, 4);
break;
default:
g.fillStyle = '#7f8c8d';
g.fillRect(4, 4, 24, 24);
g.fillStyle = 'rgba(255,255,255,0.2)';
g.fillRect(4, 4, 24, 4);
}
return c;
}
function makeTex(type) {
const t = BLOCKS[type];
const c = document.createElement('canvas');
c.width = 32; c.height = 32;
const g = c.getContext('2d');
if (type === 'tnt') {
g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32);
g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8);
g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20);
return c;
}
if (type === 'campfire') {
g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6);
g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4);
return c;
}
if (type === 'torch') {
g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18);
g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8);
return c;
}
if (type === 'glass') {
g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32);
g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28);
g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke();
return c;
}
if (type === 'water') {
g.fillStyle = t.c; g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(255,255,255,0.08)';
g.fillRect(0, 6, 32, 2);
return c;
}
if (type === 'bed') {
g.fillStyle = '#e91e63';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#f8bbd0';
g.fillRect(2, 2, 14, 14);
g.fillStyle = '#c2185b';
g.fillRect(16, 4, 14, 24);
g.fillStyle = '#e91e63';
g.fillRect(18, 6, 10, 20);
return c;
}
if (type === 'flower') {
g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18);
g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill();
return c;
}
if (type === 'boat') {
g.fillStyle = '#8B4513';
g.fillRect(2, 12, 28, 8);
g.fillStyle = '#A0522D';
g.fillRect(0, 10, 32, 12);
g.fillStyle = '#DEB887';
g.fillRect(4, 14, 24, 4);
g.fillStyle = '#654321';
g.fillRect(2, 20, 28, 4);
return c;
}
if (type === 'ladder') {
g.fillStyle = '#8B4513';
g.fillRect(4, 0, 4, 32);
g.fillRect(24, 0, 4, 32);
g.fillStyle = '#A0522D';
g.fillRect(4, 4, 24, 3);
g.fillRect(4, 12, 24, 3);
g.fillRect(4, 20, 24, 3);
g.fillRect(4, 28, 24, 3);
return c;
}
// === BIOME BLOCK TEXTURES ===
if (type === 'snow') {
g.fillStyle = '#ecf0f1';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#dfe6e9';
for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3);
return c;
}
if (type === 'ice') {
g.fillStyle = '#74b9ff';
g.fillRect(0, 0, 32, 32);
g.strokeStyle = 'rgba(255,255,255,0.4)';
g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke();
g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke();
return c;
}
if (type === 'cactus') {
g.fillStyle = '#27ae60';
g.fillRect(6, 2, 20, 28);
g.fillStyle = '#2ecc71';
g.fillRect(2, 8, 6, 4);
g.fillRect(24, 14, 6, 4);
g.fillStyle = '#1e8449';
g.fillRect(12, 0, 2, 30);
g.fillRect(18, 0, 2, 30);
return c;
}
if (type === 'mushroom') {
g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12);
g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill();
g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3);
return c;
}
if (type === 'moss') {
g.fillStyle = '#0a6640';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#1e8449';
for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3);
return c;
}
if (type === 'swamp_water') {
g.fillStyle = 'rgba(100,120,40,0.6)';
g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(80,100,20,0.3)';
g.fillRect(0, 10, 32, 2);
g.fillRect(0, 22, 32, 2);
return c;
}
if (type === 'farmland') {
g.fillStyle = '#8B6914';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#7a5c10';
for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2);
g.fillStyle = '#6B4E0A';
g.fillRect(8, 2, 2, 28);
g.fillRect(18, 2, 2, 28);
return c;
}
if (type === 'dead_bush') {
g.fillStyle = '#b2bec3';
g.fillRect(12, 16, 2, 16);
g.fillRect(8, 14, 8, 2);
g.fillRect(16, 12, 8, 2);
g.fillRect(6, 18, 4, 2);
return c;
}
if (type === 'spruce_leaves') {
g.fillStyle = '#0a6640';
g.fillRect(0, 0, 32, 32);
g.fillStyle = '#0d7a4d';
for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4);
return c;
}
// CROP STAGES
if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) {
const st = parseInt(type.charAt(type.length-1));
const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] :
type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] :
['#a8e6a0','#c8b888','#bfaa78','#dfe6e9'];
g.fillStyle = '#5d4037';
g.fillRect(15, 18, 2, 14);
if (st >= 1) {
g.fillStyle = colors[st];
g.fillRect(10, 8 + (3-st)*3, 12, 10);
} else {
g.fillStyle = '#a8e6a0';
g.fillRect(14, 22, 4, 4);
}
return c;
}
g.fillStyle = t.c || '#000';
g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(0,0,0,0.10)';
for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4);
if (type.endsWith('_ore') || type === 'coal') {
g.fillStyle = 'rgba(0,0,0,0.35)';
for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6);
}
return c;
}
function initTextures() {
Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
// Item/tool sprites
const itemIds = [
'arrow','bow',
'wood_pickaxe','stone_pickaxe','iron_pickaxe',
'wood_axe','stone_axe','iron_axe',
'wood_shovel','stone_shovel','iron_shovel',
'wood_sword','stone_sword','iron_sword',
'iron_ingot','gold_ingot','copper_ingot',
'meat','cooked',
'wheat','bread','carrot','potato','baked_potato','mushroom_stew','wheat_seeds',
'chicken_meat','feather','bone','gunpowder',
'scorpion_stinger','polar_fur','slime_ball','eagle_feather',
'gold_armor',
];
itemIds.forEach(id => { itemTex[id] = makeItemTex(id); });
}
// === ui/hotbar.js ===
// Hotbar UI — 3-slot Minecraft-style bottom bar + selected item indicator
const HOTBAR_SLOTS = 3;
let selectedItemEl = null;
function addToRecentItems(id) {
if (!id) return;
state.recentItems = state.recentItems.filter(item => item !== id);
state.recentItems.unshift(id);
state.recentItems = state.recentItems.slice(0, 5);
}
function renderSlotIcon(el, id) {
if (BLOCKS[id] && tex[id]) {
el.style.backgroundImage = `url(${tex[id].toDataURL()})`;
el.style.backgroundSize = 'cover';
} else if (itemTex[id]) {
el.style.backgroundImage = `url(${itemTex[id].toDataURL()})`;
el.style.backgroundSize = 'cover';
} else if (ITEMS[id]) {
el.textContent = ITEMS[id].icon || '📦';
} else if (TOOLS[id]) {
el.textContent = TOOLS[id].icon || '🔧';
} else if (id === 'iron_armor') {
el.textContent = '🛡️';
el.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
} else {
el.textContent = id.charAt(0).toUpperCase();
el.style.background = '#555';
el.style.color = '#fff';
el.style.fontWeight = '700';
el.style.fontSize = '14px';
}
}
function updateSelectedItem() {
return; // selectedItem removed — highlight in hotbar is enough
if (!selectedItemEl) selectedItemEl = document.getElementById('selectedItem');
if (!selectedItemEl) return;
const sel = state.selected;
if (!sel || !state.inv[sel] || state.inv[sel] <= 0) {
selectedItemEl.style.display = 'none';
return;
}
selectedItemEl.style.display = 'flex';
// Clear previous content
selectedItemEl.textContent = '';
selectedItemEl.style.backgroundImage = '';
selectedItemEl.style.background = 'rgba(0,0,0,0.45)';
// Render icon
if (BLOCKS[sel] && tex[sel]) {
selectedItemEl.style.backgroundImage = `url(${tex[sel].toDataURL()})`;
selectedItemEl.style.backgroundSize = 'cover';
} else if (itemTex[sel]) {
selectedItemEl.style.backgroundImage = `url(${itemTex[sel].toDataURL()})`;
selectedItemEl.style.backgroundSize = 'cover';
} else if (ITEMS[sel]) {
selectedItemEl.textContent = ITEMS[sel].icon || '📦';
} else if (TOOLS[sel]) {
selectedItemEl.textContent = TOOLS[sel].icon || '🔧';
} else {
selectedItemEl.textContent = sel.charAt(0).toUpperCase();
}
}
function rebuildHotbar() {
const hotbarEl = state.hotbarEl;
const inv = state.inv;
const selected = state.selected;
const toolDurability = state.toolDurability;
hotbarEl.innerHTML = '';
// Get up to 3 recent items with count > 0
const items = state.recentItems.filter(id => inv[id] > 0).slice(0, HOTBAR_SLOTS);
// If selected item not in list and has count > 0, push to front
if (inv[selected] > 0 && !items.includes(selected)) {
items.unshift(selected);
if (items.length > HOTBAR_SLOTS) items.pop();
}
// Always render HOTBAR_SLOTS slots (fill empties)
for (let si = 0; si < HOTBAR_SLOTS; si++) {
const id = items[si] || null;
const s = document.createElement('div');
s.className = 'slot' + (id && id === selected ? ' sel' : '');
// Slot number indicator (top-left)
const numEl = document.createElement('div');
numEl.style.cssText = 'position:absolute;top:1px;left:3px;font-size:10px;color:rgba(255,255,255,0.55);font-weight:700;pointer-events:none;text-shadow:1px 1px 1px rgba(0,0,0,0.7);';
numEl.textContent = (si + 1).toString();
s.appendChild(numEl);
if (id) {
renderSlotIcon(s, id);
const c = document.createElement('div');
c.className = 'count';
c.textContent = inv[id];
s.appendChild(c);
s.onclick = () => {
playSound('click');
state.selected = id;
// Move to front of recentItems
state.recentItems = state.recentItems.filter(item => item !== id);
state.recentItems.unshift(id);
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);
}
// Update selected item indicator above hotbar
updateSelectedItem();
}
// === 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);
}
// === ui/inventory.js ===
// Inventory UI
function renderInventory() {
const inventoryGrid = state.inventoryGrid;
const inv = state.inv;
const selected = state.selected;
inventoryGrid.innerHTML = '';
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];
// Canvas sprite: block > itemTex > emoji fallback > unknown item fallback
if (BLOCKS[id] && tex[id]) {
slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
slot.style.backgroundSize = 'cover';
} else if (itemTex[id]) {
slot.style.backgroundImage = `url(${itemTex[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%)';
} else {
// Unknown item fallback — grey box with first letter
slot.textContent = id.charAt(0).toUpperCase();
slot.style.background = '#555';
slot.style.color = '#fff';
slot.style.fontWeight = '700';
slot.style.fontSize = '14px';
}
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);
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;
} else {
state.player.equippedArmor = 'iron_armor';
state.player.armor = BLOCKS['iron_armor'].armor;
}
playSound('click');
renderInventory();
}
};
}
inventoryGrid.appendChild(slot);
}
}
// === game/save.js ===
// ==================== СИСТЕМА СОХРАНЕНИЯ ИГРЫ ====================
const SAVE_STORAGE_KEY = state.SAVE_KEY;
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_STORAGE_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_STORAGE_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('Миграция завершена');
}
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');
// Восстанавливаем только игрока и инвентарь
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;
}
if (saveData.inventory) {
for (const key in saveData.inventory) {
state.inv[key] = saveData.inventory[key];
}
}
if (state.recentItems.length === 0) {
state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5);
}
rebuildHotbar();
if (typeof renderInventory === 'function') renderInventory();
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();
renderInventory();
console.log('Игра загружена');
}
// === ui/share.js ===
// ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
function initShare() {
// Keep worldId click-to-copy as before
const worldIdEl = document.getElementById('worldId');
if (worldIdEl) {
worldIdEl.onclick = () => shareWorld();
}
}
function shareWorld() {
const shareUrl = new URL(window.location.href);
shareUrl.searchParams.set('world', state.worldId);
const url = shareUrl.toString();
// In Telegram WebApp — use native share
if (isTgWebApp && window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.switchInlineQuery) {
const inviteUrl = 'https://t.me/' + (state.TELEGRAM_BOT_USERNAME || 'grechka_mkn8n_bot') + '/app?startapp=' + encodeURIComponent(state.worldId);
Telegram.WebApp.openTelegramLink(
'https://t.me/share/url?url=' + encodeURIComponent(inviteUrl) +
'&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Код мира: ' + state.worldId)
);
return;
}
// Otherwise — show custom modal with URL + copy button
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:100000;display:flex;align-items:center;justify-content:center;';
const box = document.createElement('div');
box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:20px;max-width:400px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;';
const title = document.createElement('div');
title.textContent = '📤 Пригласить друзей';
title.style.cssText = 'font-size:18px;font-weight:bold;margin-bottom:12px;color:#222;';
box.appendChild(title);
const codeLabel = document.createElement('div');
codeLabel.style.cssText = 'font-size:13px;color:#444;font-weight:bold;margin-bottom:4px;';
codeLabel.textContent = 'Код мира:';
box.appendChild(codeLabel);
const codeBox = document.createElement('div');
codeBox.style.cssText = 'font-size:20px;font-weight:bold;background:#fff;border:3px solid #000;padding:8px 12px;margin-bottom:12px;letter-spacing:2px;text-align:center;color:#5A8F3C;';
codeBox.textContent = state.worldId;
box.appendChild(codeBox);
const linkLabel = document.createElement('div');
linkLabel.style.cssText = 'font-size:13px;color:#444;font-weight:bold;margin-bottom:4px;';
linkLabel.textContent = 'Ссылка:';
box.appendChild(linkLabel);
const linkBox = document.createElement('input');
linkBox.type = 'text';
linkBox.readOnly = true;
linkBox.value = url;
linkBox.style.cssText = 'width:100%;padding:8px;font-family:Courier New,monospace;font-size:13px;background:#fff;border:3px solid #000;color:#222;margin-bottom:12px;box-sizing:border-box;';
linkBox.onclick = () => linkBox.select();
box.appendChild(linkBox);
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:8px;';
const btnCopy = document.createElement('button');
btnCopy.textContent = '📋 Скопировать';
btnCopy.style.cssText = 'flex:1;padding:10px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;';
btnCopy.onclick = () => {
navigator.clipboard.writeText(url).then(() => {
showToast('📋 Ссылка скопирована!');
overlay.remove();
}).catch(() => {
linkBox.select();
document.execCommand('copy');
showToast('📋 Ссылка скопирована!');
overlay.remove();
});
};
btnRow.appendChild(btnCopy);
const btnCode = document.createElement('button');
btnCode.textContent = '📋 Код';
btnCode.style.cssText = 'padding:10px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#3C5F9E;color:#fff;box-shadow:2px 2px 0 #000;';
btnCode.onclick = () => {
navigator.clipboard.writeText(state.worldId).then(() => {
showToast('📋 Код скопирован: ' + state.worldId);
}).catch(() => {
showToast('Код: ' + state.worldId);
});
};
btnRow.appendChild(btnCode);
const btnClose = document.createElement('button');
btnClose.textContent = '✕';
btnClose.style.cssText = 'padding:10px 14px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#9E2B2B;color:#fff;box-shadow:2px 2px 0 #000;';
btnClose.onclick = () => overlay.remove();
btnRow.appendChild(btnClose);
box.appendChild(btnRow);
overlay.appendChild(box);
// Close on click outside box
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
document.body.appendChild(overlay);
}
// === 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 shareItem = document.getElementById('shareItem');
const settingsItem = document.getElementById('settingsItem');
const quitItem = document.getElementById('quitItem');
// Убить возможный старый onclick
if (menuBtn) menuBtn.onclick = null;
// Тогл меню
if (menuBtn) {
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
dropdownOpen = !dropdownOpen;
if (menuDropdown) menuDropdown.classList.toggle('open', dropdownOpen);
});
}
// Закрытие по клику вне
document.addEventListener('click', () => {
if (dropdownOpen) {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
}
});
if (menuDropdown) {
menuDropdown.addEventListener('click', (e) => e.stopPropagation());
}
// 💾 Сохранить
if (saveItem) {
saveItem.addEventListener('click', () => {
playSound('click');
saveGame();
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
alert('Игра сохранена!');
});
}
// 🔄 Новый мир
if (resetItem) {
resetItem.addEventListener('click', () => {
if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
playSound('click');
try { localStorage.removeItem(state.SAVE_KEY); } catch(e) {}
state.inMemorySave = null;
state.worldId = Math.random().toString(36).substring(2, 10);
try {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('world', state.worldId);
window.history.replaceState(null, '', newUrl.toString());
} catch(e) {}
location.reload();
} else {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
}
});
}
// 🏪 Рынок
if (marketItem) {
marketItem.addEventListener('click', () => {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
document.dispatchEvent(new CustomEvent('toggleMarket'));
});
}
// 📤 Пригласить
if (shareItem) {
shareItem.addEventListener('click', () => {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
shareWorld();
});
}
// ⚙️ Настройки
if (settingsItem) {
settingsItem.addEventListener('click', () => {
dropdownOpen = false;
menuDropdown.classList.remove('open');
const panel = document.getElementById('settingsPanel');
if (!panel) return;
// Sync controls with current state
const toggle = document.getElementById('dayNightToggle');
const timeSlider = document.getElementById('timeSlider');
const wSlider = document.getElementById('weatherIntensity');
if (toggle) toggle.checked = state.dayNightCycle !== false;
if (timeSlider) timeSlider.value = Math.round(state.worldTime * 100);
if (wSlider) wSlider.value = Math.round(state.rainIntensity * 100);
// Sync weather buttons — highlight the active type
const wt = state.weatherType || (state.isRaining ? 'rain' : 'clear');
const activeId = 'weather' + wt.charAt(0).toUpperCase() + wt.slice(1);
for (const bid of ['weatherClear','weatherRain','weatherSnow','weatherThunder']) {
const b = document.getElementById(bid);
if (b) b.style.background = (bid === activeId) ? '#3498db' : '#555';
}
panel.style.display = 'block';
});
}
// Закрытие настроек
const settingsClose = document.getElementById('settingsClose');
if (settingsClose) {
settingsClose.addEventListener('click', () => {
const settingsPanel = document.getElementById('settingsPanel');
if (settingsPanel) settingsPanel.style.display = 'none';
});
}
// Тогл день/ночи
const dayNightToggle = document.getElementById('dayNightToggle');
if (dayNightToggle) {
dayNightToggle.addEventListener('change', () => {
state.dayNightCycle = dayNightToggle.checked;
saveGame();
});
}
// 🏠 Главное меню
if (quitItem) {
quitItem.addEventListener('click', () => {
dropdownOpen = false;
if (menuDropdown) menuDropdown.classList.remove('open');
saveGame();
try {
const url = new URL(window.location.href);
url.searchParams.delete('world');
window.location.href = url.toString();
} catch(e) { location.reload(); }
});
}
}
function updateSaveButtonVisibility() {
const saveItem = document.getElementById('saveItem');
if (!saveItem) return;
saveItem.style.display = (state.isMultiplayer && state.otherPlayers.size > 0) ? 'none' : '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;
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 });
}
// === ui/trade.js ===
// Trade UI — личная торговля (лицом к лицу)
function itemName(id) {
return BLOCKS[id]?.n || TOOLS[id]?.n || ITEMS[id]?.n || id;
}
function itemIcon(id) {
if (BLOCKS[id]) return '🟫';
if (TOOLS[id]) return TOOLS[id].icon || '🔧';
if (ITEMS[id]) return ITEMS[id].icon || '📦';
return '📦';
}
let tradeOpen = false;
let currentTradeId = null;
let myOffer = {};
let partnerOffer = {};
let iConfirmed = false;
function isTradeOpen() { return tradeOpen; }
function initTradeSocket(socket) {
// Incoming trade request
socket.on('trade_request', (data) => {
playSound('click');
// Show trade request modal
const overlay = document.createElement('div');
overlay.id = 'tradeRequestOverlay';
overlay.className = 'custom-modal-overlay';
overlay.innerHTML = `
<div class="custom-modal-box">
<div style="font-size:18px;margin-bottom:12px;">🤝 ${data.from_name} хочет торговать</div>
<div class="modal-btns">
<button id="tradeAcceptBtn" style="background:#2ecc71;padding:10px 20px;border:none;border-radius:10px;color:#fff;font-weight:900;font-size:15px;cursor:pointer;">Принять</button>
<button id="tradeDeclineBtn" style="background:#e74c3c;padding:10px 20px;border:none;border-radius:10px;color:#fff;font-weight:900;font-size:15px;cursor:pointer;">Отклонить</button>
</div>
</div>`;
document.getElementById('game').appendChild(overlay);
document.getElementById('tradeAcceptBtn').addEventListener('click', () => {
overlay.remove();
socket.emit('trade_accept', { from_socket_id: data.from });
});
document.getElementById('tradeDeclineBtn').addEventListener('click', () => {
overlay.remove();
socket.emit('trade_decline', { from_socket_id: data.from });
});
});
socket.on('trade_start', (data) => {
currentTradeId = data.trade_id;
myOffer = {};
partnerOffer = {};
iConfirmed = false;
tradeOpen = true;
renderTradeUI(data.partner_name);
state.tradePanel.style.display = 'block';
});
socket.on('trade_update', (data) => {
partnerOffer = data.player1Offer || data.player2Offer || {};
// Determine which offer is the partner's
const isP1 = state.socket && state.socket.id === data.player1Offer;
// The partner's offer is the one we didn't send
// Actually the server sends both — we figure out which is ours
// Simpler: just use the data directly
if (state.mySocketId) {
// We need to know which offer in the update is ours vs partner's
// The trade_update event doesn't tell us, but we know myOffer already
// So partnerOffer = the one that's not myOffer
}
renderTradeOffers();
});
socket.on('trade_status', (data) => {
// Update confirm buttons
const confirmBtn = document.getElementById('tradeConfirmBtn');
if (confirmBtn) {
const amIPlayer1 = true; // We need to track this
if (iConfirmed) {
confirmBtn.textContent = '✅ Ожидание...';
confirmBtn.disabled = true;
confirmBtn.style.background = '#555';
}
}
// Show partner confirmation status
const partnerStatus = document.getElementById('partnerConfirmStatus');
if (partnerStatus) {
const partnerConfirmed = data.player1Confirm || data.player2Confirm;
partnerStatus.textContent = partnerConfirmed ? '✅ Подтвердил' : '⏳ Ещё не подтвердил';
}
});
socket.on('trade_complete', (data) => {
// Add received items to inventory
const received = data.received || {};
for (const [item, qty] of Object.entries(received)) {
if (qty > 0) {
state.inv[item] = (state.inv[item] || 0) + qty;
}
}
// Remove sent items from inventory
const sent = data.sent || {};
for (const [item, qty] of Object.entries(sent)) {
if (qty > 0) {
state.inv[item] = (state.inv[item] || 0) - qty;
if (state.inv[item] < 0) state.inv[item] = 0;
}
}
addChatMessage('Система', '🤝 Сделка завершена!');
closeTrade();
});
socket.on('trade_declined', () => {
addChatMessage('Система', 'Торговля отклонена');
closeTrade();
});
socket.on('trade_cancelled', () => {
addChatMessage('Система', 'Торговля отменена');
closeTrade();
});
socket.on('trade_error', (data) => {
addChatMessage('Система', '❌ ' + data.msg);
});
}
function renderTradeUI(partnerName) {
const panel = state.tradePanel;
const content = state.tradeContent;
content.innerHTML = `
<div style="text-align:center;color:#f1c40f;font-weight:900;font-size:16px;margin-bottom:8px;">🤝 Торговля с ${partnerName}</div>
<div style="display:flex;gap:12px;">
<div id="myOfferArea" style="flex:1;">
<div style="color:#2ecc71;font-weight:900;font-size:13px;margin-bottom:4px;">Вы предлагаете:</div>
<div id="myOfferItems" style="min-height:40px;padding:6px;border-radius:8px;background:rgba(46,204,113,0.1);border:1px solid rgba(46,204,113,0.3);"></div>
<button id="tradeAddItemBtn" style="width:100%;margin-top:6px;padding:6px;background:#3498db;color:#fff;border:none;border-radius:6px;font-weight:700;cursor:pointer;">+ Добавить</button>
</div>
<div style="display:flex;align-items:center;font-size:24px;">⇄</div>
<div id="partnerOfferArea" style="flex:1;">
<div style="color:#e74c3c;font-weight:900;font-size:13px;margin-bottom:4px;">Партнёр предлагает:</div>
<div id="partnerOfferItems" style="min-height:40px;padding:6px;border-radius:8px;background:rgba(231,76,60,0.1);border:1px solid rgba(231,76,60,0.3);">
<span style="color:#888;font-size:12px;">Ожидание...</span>
</div>
</div>
</div>
<div id="partnerConfirmStatus" style="color:#bbb;font-size:11px;text-align:center;margin-top:6px;">⏳ Партнёр ещё не подтвердил</div>
<div style="display:flex;gap:8px;margin-top:10px;">
<button id="tradeConfirmBtn" style="flex:1;padding:8px;background:#2ecc71;color:#fff;border:none;border-radius:8px;font-weight:900;cursor:pointer;">✅ Подтвердить</button>
<button id="tradeCancelBtn" style="flex:1;padding:8px;background:#e74c3c;color:#fff;border:none;border-radius:8px;font-weight:900;cursor:pointer;">✕ Отмена</button>
</div>`;
document.getElementById('tradeAddItemBtn')?.addEventListener('click', showAddItemDialog);
document.getElementById('tradeConfirmBtn')?.addEventListener('click', confirmTrade);
document.getElementById('tradeCancelBtn')?.addEventListener('click', cancelTrade);
}
function renderTradeOffers() {
const myItems = document.getElementById('myOfferItems');
if (myItems) {
const keys = Object.keys(myOffer).filter(k => myOffer[k] > 0);
if (keys.length === 0) {
myItems.innerHTML = '<span style="color:#888;font-size:12px;">Ничего</span>';
} else {
myItems.innerHTML = keys.map(k => `<div style="color:#fff;font-size:12px;">${itemIcon(k)} ${itemName(k)} x${myOffer[k]}</div>`).join('');
}
}
}
function showAddItemDialog() {
const inv = state.inv;
const items = Object.keys(inv).filter(id => inv[id] > 0);
if (items.length === 0) {
addChatMessage('Система', 'Инвентарь пуст!');
return;
}
const overlay = document.createElement('div');
overlay.id = 'tradeItemPicker';
overlay.className = 'custom-modal-overlay';
let html = '<div class="custom-modal-box" style="max-height:80vh;overflow-y:auto;max-width:360px;"><div style="font-weight:900;font-size:16px;margin-bottom:8px;">Что предложить?</div>';
for (const id of items) {
// Show already offered count
const offered = myOffer[id] || 0;
const available = inv[id] - offered;
if (available <= 0) continue;
html += `<div style="display:flex;align-items:center;gap:8px;padding:6px;border-radius:6px;cursor:pointer;border:1px solid rgba(255,255,255,0.1);margin-bottom:4px;" class="trade-pick-item" data-item="${id}">
<span style="font-size:18px;">${itemIcon(id)}</span>
<span style="flex:1;color:#fff;font-size:13px;">${itemName(id)}</span>
<span style="color:#bbb;font-size:11px;">x${available}</span>
</div>`;
}
html += '</div></div>';
overlay.innerHTML = html;
document.getElementById('game').appendChild(overlay);
overlay.querySelectorAll('.trade-pick-item').forEach(el => {
el.addEventListener('click', () => {
const itemId = el.dataset.item;
// Ask quantity
const available = inv[itemId] - (myOffer[itemId] || 0);
const qty = Math.min(available, available > 1 ? promptForQty(itemId, available) : 1);
if (qty > 0) {
myOffer[itemId] = (myOffer[itemId] || 0) + qty;
renderTradeOffers();
// Reset confirm when offer changes
iConfirmed = false;
const btn = document.getElementById('tradeConfirmBtn');
if (btn) { btn.textContent = '✅ Подтвердить'; btn.disabled = false; btn.style.background = '#2ecc71'; }
}
overlay.remove();
});
});
}
function promptForQty(itemId, max) {
// Simple: just use 1 for now, can enhance later with a proper dialog
const val = parseInt(prompt(`Сколько ${itemName(itemId)}? (макс: ${max})`, '1'));
return isNaN(val) || val < 1 ? 1 : Math.min(val, max);
}
function confirmTrade() {
if (!currentTradeId || !state.socket) return;
// Deduct offered items from inventory
for (const [item, qty] of Object.entries(myOffer)) {
if (qty > 0) {
if (state.inv[item] < qty) {
addChatMessage('Система', `Недостаточно ${itemName(item)}!`);
return;
}
}
}
// Send offer + confirm
state.socket.emit('trade_offer', { trade_id: currentTradeId, offer: myOffer });
// Small delay then confirm
setTimeout(() => {
state.socket.emit('trade_confirm', { trade_id: currentTradeId });
iConfirmed = true;
const btn = document.getElementById('tradeConfirmBtn');
if (btn) { btn.textContent = '✅ Ожидание...'; btn.disabled = true; btn.style.background = '#555'; }
}, 200);
}
function cancelTrade() {
if (!currentTradeId || !state.socket) return;
state.socket.emit('trade_cancel', { trade_id: currentTradeId });
closeTrade();
}
function closeTrade() {
tradeOpen = false;
currentTradeId = null;
myOffer = {};
partnerOffer = {};
iConfirmed = false;
if (state.tradePanel) state.tradePanel.style.display = 'none';
// Remove any pick overlay
document.getElementById('tradeItemPicker')?.remove();
}
function requestTradeWithPlayer(targetName) {
if (!state.socket || !state.isMultiplayer) {
addChatMessage('Система', 'Торговля доступна только в мультиплеере');
return;
}
state.socket.emit('trade_request', { target_name: targetName });
addChatMessage('Система', `Запрос отправлен ${targetName}...`);
}
// === multiplayer/socket.js ===
// Socket.IO клиент — инициализация и все обработчики
// Mob sizes for server mob sync (server doesn't send w/h)
const MOB_SIZES = {
pig: { w: 34, h: 24 },
chicken: { w: 26, h: 22 },
zombie: { w: 34, h: 50 },
creeper: { w: 34, h: 50 },
skeleton: { w: 34, h: 50 },
scorpion: { w: 26, h: 26 },
polar_bear: { w: 40, h: 34 },
slime: { w: 24, h: 24 },
eagle: { w: 30, h: 22 },
};
function applyMobSize(sm) {
const s = MOB_SIZES[sm.kind];
if (s && !sm.w) { sm.w = s.w; sm.h = s.h; }
return sm;
}
let socket = null;
function initSocket() {
try {
socket = io(SERVER_URL, {
path: '/socket.io/',
transports: ['websocket', 'polling']
});
// Store socket on state for other modules
state.socket = socket;
socket.on('connect', () => {
console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${state.worldId}`);
state.mySocketId = socket.id;
state.isMultiplayer = true;
// Присоединяемся к миру
socket.emit('join_world', { world_id: state.worldId, player_name: state.playerName });
// Показываем в UI
if (state.worldIdEl) state.worldIdEl.textContent = state.worldId;
if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'block';
// Initialize trade socket handlers
initTradeSocket(socket);
});
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();
if (state.multiplayerStatus) state.multiplayerStatus.style.display = 'none';
});
// Обработка world_state
socket.on('world_state', (data) => {
console.log('Received world_state:', data);
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);
state.generated.clear();
grid.clear();
blocks.length = 0;
state.placedBlocks = [];
state.removedBlocks = [];
console.log('World regenerated with new seed:', state.worldSeed);
}
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;
}
// Weather from server
if (data.weatherType) {
state.weatherType = data.weatherType;
state.weatherChangeInterval = Infinity;
state.isRaining = (data.weatherType === 'rain' || data.weatherType === 'storm' || data.weatherType === 'thunder');
}
// Spawn point
{
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);
state.player.hp = 100;
state.player.hunger = 100;
state.player.o2 = 100;
state.player.invuln = 0;
// Mark that we've received authoritative world state — prevents local save override
state._worldSynced = true;
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) {
let 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 };
applyMobSize(sm);
state.serverMobs.set(m.id, sm);
}
}
// Set myTgId from server if provided
if (data.tg_id) state.myTgId = data.tg_id;
});
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 ===
socket.on('mob_spawned', (data) => {
let 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 };
applyMobSize(sm);
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) {
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;
}
});
// Weather sync from server (multiplayer)
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');
}
});
} 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 W = state.W;
const H = state.H;
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' || b.t === 'swamp_water'));
}
function isSwampWaterAt(px, py) {
const gx = Math.floor(px / TILE);
const gy = Math.floor(py / TILE);
const b = getBlock(gx, gy);
return !!(b && b.t === 'swamp_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.inSwampWater = isSwampWaterAt(cx, e.y + e.h / 2);
// Голова под водой — для кислорода/урона
e.headInWater = isWaterAt(cx, e.y + 4);
// Звук при падении в воду
if (e === state.player && !wasInWater && e.inWater && e.vy > 100) {
playSound('splash');
}
// Болотная вода — отравление
if (e === state.player && e.inSwampWater) {
state.player.hp -= 3 * (typeof dt !== 'undefined' ? dt : 0.016);
}
}
// === 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 if (m.kind === 'scorpion') {
// Скорпион — бежит к игроку, яд (замедление)
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(8);
state.player.hp -= damage;
state.player.invuln = 0.8;
state.player.slowTimer = 3;
state.player.vx += dir * 300;
state.player.vy -= 200;
playSound('hit1');
}
} else if (m.kind === 'polar_bear') {
// Белый медведь — нейтрален, бродит
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 2.0 + Math.random() * 3;
m.dir = Math.random() < 0.5 ? -1 : 1;
if (Math.random() < 0.3) m.dir = 0;
}
m.vx = m.dir * m.speed;
if (m.inWater) m.vy = -120;
} else if (m.kind === 'slime') {
// Слизь — прыгает к игроку
const dir = Math.sign((state.player.x + state.player.w / 2) - (m.x + m.w / 2));
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 1.5 + Math.random() * 1.5;
m.dir = dir;
m.vy = -200;
}
m.vx = m.dir * m.speed;
} else if (m.kind === 'eagle') {
// Орёл — летает, атакует пикированием
const dx = (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 dist = Math.hypot(dx, dy);
if (dist < 400) {
m.vx = Math.sign(dx) * m.speed;
m.vy = dy > 0 ? 60 : -60;
} else {
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 2 + Math.random() * 3;
m.dir = Math.random() < 0.5 ? -1 : 1;
}
m.vx = m.dir * m.speed * 0.5;
m.vy = Math.sin(performance.now() / 1000) * 30;
}
// Атака при касании
if (dist < 30 && state.player.invuln <= 0) {
const damage = calculateDamage(10);
state.player.hp -= damage;
state.player.invuln = 0.8;
state.player.vy -= 200;
playSound('hit1');
}
// Орёл не падает — летающий моб
m.vy *= 0.5;
m.grounded = false;
} else {
// животные (pig, chicken)
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 1.8 + Math.random() * 2.5;
m.dir = Math.random() < 0.5 ? -1 : 1;
if (Math.random() < 0.25) m.dir = 0;
}
m.vx = m.dir * (m.kind === 'chicken' ? 55 : 40);
if (m.inWater) m.vy = -120;
}
// Мобы прыгают при столкновении со стеной
if (m.grounded && Math.abs(m.vx) > 5 && m.kind !== 'eagle') {
const probeX = m.vx > 0 ? m.x + m.w + 2 : m.x - 2;
const probeGY = Math.floor((m.y + m.h - 1) / TILE);
if (isSolid(Math.floor(probeX / TILE), probeGY)) {
m.vy = -280;
}
}
// физика моба
const g = (m.inWater && m.kind !== 'eagle') ? 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() {
return state.worldTime > 0.5;
}
// Mob loot table
function getMobLoot(kind) {
const table = {
chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}],
pig: [{item:'meat',min:1,max:2,chance:1}],
zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}],
skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}],
creeper: [{item:'gunpowder',min:1,max:2,chance:1}],
scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}],
polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}],
slime: [{item:'slime_ball',min:1,max:2,chance:1}],
eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}]
};
return table[kind] || [];
}
// === 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:'wood_axe', qty:1, cost:{ wood:3, planks:2 } },
{ out:'stone_axe', qty:1, cost:{ wood:1, stone:3 } },
{ out:'iron_axe', qty:1, cost:{ wood:1, iron_ore:2 } },
{ out:'wood_shovel', qty:1, cost:{ wood:1, planks:1 } },
{ out:'stone_shovel', qty:1, cost:{ wood:1, stone:2 } },
{ out:'iron_shovel', qty:1, cost:{ wood:1, iron_ore:1 } },
{ out:'iron_armor', qty:1, cost:{ iron_ore: 5 } },
{ out:'gold_armor', qty:1, cost:{ gold_ore: 8 } },
{ 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 } },
{ out:'flag', qty:1, cost:{ planks:3, iron_ore:1 } },
{ out:'bread', qty:1, cost:{ wheat: 3 } },
{ out:'farmland', qty:1, cost:{ dirt:1 } },
{ out:'wheat_seeds', qty:1, cost:{ farmland:1, dirt:1 } },
];
// Рецепты печи (обжиг)
const SMELTING_RECIPES = [
{ in:'sand', qty:1, out:'glass', outQty:1, time:3 },
{ in:'clay', qty:1, out:'brick', outQty:1, time:3 },
{ in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 },
{ in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 },
{ in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 },
{ in:'meat', qty:1, out:'cooked', outQty:1, time:2 },
{ in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 },
{ in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 }
];
// === 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 ===
// ==================== ПОГОДА (biome-aware) ====================
// Возможные погоды по биому
const BIOME_WEATHER = {
snow: ['clear', 'snow', 'blizzard'],
tundra: ['clear', 'snow', 'blizzard'],
forest: ['clear', 'rain', 'storm'],
plains: ['clear', 'rain'],
desert: ['clear', 'clear', 'rain'],
swamp: ['clear', 'rain', 'storm'],
mountains: ['clear', 'rain', 'snow']
};
// Помощник интерполяции цветов
function lerpColor(a, b, t) {
const ar = parseInt(a.slice(1,3),16), ag = parseInt(a.slice(3,5),16), ab = parseInt(a.slice(5,7),16);
const br = parseInt(b.slice(1,3),16), bg = parseInt(b.slice(3,5),16), bb = parseInt(b.slice(5,7),16);
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const bl = Math.round(ab + (bb - ab) * t);
return '#' + ((1<<24)|(r<<16)|(g<<8)|bl).toString(16).slice(1);
}
// Цвета неба по биому, погоде и времени суток
function skyColor(night, biome, weatherType, worldTime) {
function weatherSky() {
switch (weatherType) {
case 'blizzard': return '#8a9bae';
case 'storm': return '#3d4f5f';
case 'rain': return '#6B7B8D';
case 'snow': return '#b8c6d4';
case 'clear': return null;
default: return null;
}
}
const dayColor = (biome === 'desert') ? '#f4d9a0' : (biome === 'tundra' || biome === 'snow') ? '#c4d4e0' : (biome === 'swamp') ? '#4a6741' : '#87CEEB';
const dawnColor = '#FF9940';
const duskMidColor = '#FF6B35';
const duskEndColor = '#1a0533';
const nightColor = '#070816';
let skyColorResult;
if (worldTime < 0.05) {
const t = worldTime / 0.05;
skyColorResult = lerpColor(dawnColor, dayColor, t);
} else if (worldTime < 0.45) {
skyColorResult = dayColor;
} else if (worldTime < 0.55) {
const t = (worldTime - 0.45) / 0.1;
if (t < 0.5) {
skyColorResult = lerpColor(dayColor, duskMidColor, t * 2);
} else {
skyColorResult = lerpColor(duskMidColor, duskEndColor, (t - 0.5) * 2);
}
} else {
skyColorResult = nightColor;
if (weatherType === 'blizzard') return '#1a1a2e';
if (weatherType === 'storm') return '#0a0a15';
return nightColor;
}
const wc = weatherSky();
if (wc) {
const blendT = 0.6;
skyColorResult = lerpColor(skyColorResult, wc, blendT);
}
return skyColorResult;
}
function updateWeather(dt) {
state.weatherTimer += dt;
if (state.weatherTimer >= state.weatherChangeInterval) {
state.weatherTimer = 0;
state.weatherChangeInterval = 60 + Math.random() * 120;
// Определяем биом по позиции игрока
const playerGx = Math.floor(state.player.x / state.TILE);
const biome = getCachedBiome(playerGx);
const weathers = BIOME_WEATHER[biome] || ['clear'];
const night = isNight();
const weights = weathers.map(w => {
if (w === 'clear') return night ? 2 : 4;
if (w === 'rain') return night ? 3 : 2;
if (w === 'snow') return night ? 2 : 3;
if (w === 'storm') return night ? 3 : 1;
if (w === 'blizzard') return night ? 3 : 1;
return 1;
});
const total = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * total;
state.weatherType = weathers[0];
for (let i = 0; i < weathers.length; i++) {
r -= weights[i];
if (r <= 0) { state.weatherType = weathers[i]; break; }
}
state.isRaining = (state.weatherType === 'rain' || state.weatherType === 'storm');
}
// Плавная интерполяция интенсивности дождя
const rainTarget = (state.weatherType === 'rain' || state.weatherType === 'storm') ? 0.6 : 0;
state.rainIntensity += (rainTarget - state.rainIntensity) * dt * 0.5;
if (state.rainIntensity < 0.01) state.rainIntensity = 0;
// Молния (только при storm)
if (state.weatherType === 'storm') {
state.lightningTimer -= dt;
if (state.lightningTimer <= 0) {
state.lightningFlash = 0.25;
state.lightningTimer = 4 + Math.random() * 12;
}
}
if (state.lightningFlash > 0) {
state.lightningFlash -= dt;
if (state.lightningFlash < 0) state.lightningFlash = 0;
}
// Снежинки (при snow / blizzard)
if (state.weatherType === 'snow' || state.weatherType === 'blizzard') {
const spawnCount = state.weatherType === 'blizzard' ? Math.floor(80 * dt) : Math.floor(30 * dt);
const maxFlakes = state.weatherType === 'blizzard' ? 600 : 250;
for (let i = 0; i < spawnCount && state.snowflakes.length < maxFlakes; i++) {
state.snowflakes.push({
x: state.camX + Math.random() * state.W,
y: state.camY - 20,
vy: 40 + Math.random() * 60,
vx: (state.weatherType === 'blizzard' ? -60 - Math.random() * 80 : -10 - Math.random() * 20),
size: 2 + Math.random() * 3,
wobble: Math.random() * Math.PI * 2
});
}
}
// Обновление снежинок
for (let i = state.snowflakes.length - 1; i >= 0; i--) {
const s = state.snowflakes[i];
s.y += s.vy * dt;
s.x += s.vx * dt;
s.wobble += dt * 3;
s.x += Math.sin(s.wobble) * 15 * dt;
if (s.y > state.camY + state.H + 20 || s.x < state.camX - 50 || s.x > state.camX + state.W + 50) {
state.snowflakes.splice(i, 1);
}
}
// Убираем снег при смене погоды
if (state.weatherType !== 'snow' && state.weatherType !== 'blizzard' && !state.isRaining) {
if (state.snowflakes.length > 0) {
state.snowflakes.splice(0, Math.ceil(state.snowflakes.length * dt * 2));
}
}
}
function updateRain(dt) {
if (!state.isRaining || state.rainIntensity < 0.01) {
if (state.raindrops.length > 0) {
state.raindrops.splice(0, Math.ceil(state.raindrops.length * dt * 3));
}
return;
}
const spawnRate = Math.floor(state.rainIntensity * 60 * dt);
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 drawWeather(ctx) {
if (state.raindrops.length > 0) {
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();
}
if (state.snowflakes.length > 0) {
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
for (const s of state.snowflakes) {
ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (state.lightningFlash > 0) {
ctx.save();
ctx.fillStyle = `rgba(255,255,255,${Math.min(1, state.lightningFlash * 4)})`;
ctx.fillRect(0, 0, state.W, state.H);
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; }
}
class Scorpion extends Entity {
constructor(x, y) { super(x, y, 26, 26); this.kind = 'scorpion'; this.hp = 3; this.speed = 90; this.fuse = 0; this.shootCooldown = 0; }
}
class PolarBear extends Entity {
constructor(x, y) { super(x, y, 40, 34); this.kind = 'polar_bear'; this.hp = 8; this.speed = 50; this.fuse = 0; this.shootCooldown = 2; }
}
class Slime extends Entity {
constructor(x, y) { super(x, y, 24, 24); this.kind = 'slime'; this.hp = 2; this.speed = 30; this.fuse = 0; this.shootCooldown = 2; }
}
class Eagle extends Entity {
constructor(x, y) { super(x, y, 30, 22); this.kind = 'eagle'; this.hp = 3; this.speed = 120; this.fuse = 0; 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';
};
const minimapClose = document.getElementById('minimapClose');
if (minimapClose) {
minimapClose.addEventListener('click', (e) => {
e.stopPropagation();
state.minimapOpen = false;
minimapWrap.style.display = '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 ===
// ==================== ВЗАИМОДЕЙСТВИЕ МЫШЬ/ТАП ====================
// Crop stage to seed/harvest mapping
const CROP_SEED_MAP = {
wheat_stage0: 'wheat_seeds', wheat_stage1: 'wheat_seeds', wheat_stage2: 'wheat_seeds', wheat_stage3: 'wheat_seeds',
carrot_stage0: 'carrot', carrot_stage1: 'carrot', carrot_stage2: 'carrot', carrot_stage3: 'carrot',
potato_stage0: 'potato', potato_stage1: 'potato', potato_stage2: 'potato', potato_stage3: 'potato'
};
const CROP_HARVEST_MAP = { wheat_stage3: 'wheat', carrot_stage3: 'carrot', potato_stage3: 'potato' };
// Helper: handle block drops including farming items
function handleBlockDrop(removed, inv) {
// Grass drops wheat_seeds (50%)
if (removed.t === 'grass') {
if (Math.random() < 0.5) inv.wheat_seeds = (inv.wheat_seeds || 0) + 1;
return;
}
// Crop drops: seeds for stages 0-2, harvest+seed for stage 3
const harvestItem = CROP_HARVEST_MAP[removed.t];
const seedItem = CROP_SEED_MAP[removed.t];
if (harvestItem) {
inv[harvestItem] = (inv[harvestItem] || 0) + (BLOCKS[removed.t].harvestQty || 1); addToRecentItems(harvestItem);
if (seedItem) { inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem); }
} else if (seedItem) {
// Early crop stage — return seed
inv[seedItem] = (inv[seedItem] || 0) + 1; addToRecentItems(seedItem);
} else {
inv[removed.t] = (inv[removed.t] || 0) + 1; addToRecentItems(removed.t);
}
}
const mouse = { x: null, y: null };
// Hold-to-mine tracking
let miningHeld = false;
let miningInterval = 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;
}
// ====== MINING PROGRESSION SYSTEM ======
if (mode() === 'mine') {
if (!b) return;
// Allow mining crops (growthStage) and harvestable blocks, block other decor and fluids
if (BLOCKS[b.t].fluid || (BLOCKS[b.t].decor && BLOCKS[b.t].growthStage === undefined && !BLOCKS[b.t].harvestable)) return;
if (b.t === 'tnt') { activateTNT(b, 3.2); return; }
// Calculate mining progress
const hardness = BLOCKS[b.t].hardness || 1;
if (hardness === Infinity) return; // unbreakable (bedrock)
if (hardness === 0) {
// Instant-mine blocks (flower, boat, crops, etc.)
const removed = removeBlock(gx, gy);
if (removed) {
handleBlockDrop(removed, state.inv);
sendBlockChange(gx, gy, removed.t, 'remove');
rebuildHotbar();
}
return;
}
// Same block being mined?
if (state.mining.gx === gx && state.mining.gy === gy) {
state.mining.progress += getMiningSpeed(b.t);
} else {
// New block, start mining
state.mining = { gx, gy, progress: getMiningSpeed(b.t), hardness };
}
// Play mining hit sound
if (b.t === 'glass') playSound('glass1');
else if (b.t === 'sand') playSound('sand1');
else if (b.t === 'stone' || b.t.endsWith('_ore')) playSound('stone1');
else if (b.t === 'wood' || b.t === 'planks') playSound('wood1');
else playSound('cloth1');
// Check if block is fully mined
if (state.mining.progress >= hardness) {
// Check drop requirements
const minTool = BLOCKS[b.t].minTool;
let drops = true;
if (minTool) {
const toolId = getBestTool(b.t);
if (toolId) {
const tool = TOOLS[toolId];
if (toolTierLessThan(tool.toolTier, minTool)) drops = false;
else useTool(toolId);
} else {
drops = false;
}
} else {
// Use tool durability if applicable
const toolId = getBestTool(b.t);
if (toolId) useTool(toolId);
}
const removed = removeBlock(gx, gy);
if (removed) {
if (drops) {
handleBlockDrop(removed, state.inv);
} else {
// Drop cobblestone instead for stone-type blocks
if (BLOCKS[removed.t]?.toolType === 'pickaxe') {
state.inv.cobblestone = (state.inv.cobblestone || 0) + 1;
}
// Else nothing drops
}
sendBlockChange(gx, gy, removed.t, 'remove');
// Sound for block break
if (removed.t === 'glass') playSound('glass1');
else if (removed.t === 'sand') playSound('sand1');
else if (removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1');
else if (removed.t === 'wood') playSound('wood1');
else playSound('cloth1');
rebuildHotbar();
}
state.mining = { gx: null, gy: null, progress: 0, hardness: 0 };
}
// Start hold-to-mine for desktop
miningHeld = true;
if (miningInterval) clearInterval(miningInterval);
miningInterval = setInterval(() => {
if (!miningHeld) { clearInterval(miningInterval); miningInterval = null; return; }
// Re-check the block still exists at mining position
const mb = getBlock(state.mining.gx, state.mining.gy);
if (!mb || mb.dead || mb.t !== b.t) {
miningHeld = false;
clearInterval(miningInterval);
miningInterval = null;
return;
}
// Accumulate progress
if (state.mining.gx !== null && state.mining.hardness > 0) {
state.mining.progress += getMiningSpeed(mb.t) * 0.3; // 0.3x per tick for held mining
if (state.mining.progress >= state.mining.hardness) {
const mgx = state.mining.gx;
const mgy = state.mining.gy;
const mMinTool = BLOCKS[mb.t].minTool;
let mDrops = true;
if (mMinTool) {
const mToolId = getBestTool(mb.t);
if (mToolId) {
const mTool = TOOLS[mToolId];
if (toolTierLessThan(mTool.toolTier, mMinTool)) mDrops = false;
else useTool(mToolId);
} else {
mDrops = false;
}
} else {
const mToolId = getBestTool(mb.t);
if (mToolId) useTool(mToolId);
}
const removed = removeBlock(mgx, mgy);
if (removed) {
if (mDrops) {
handleBlockDrop(removed, state.inv);
} else {
if (BLOCKS[removed.t]?.toolType === 'pickaxe') {
state.inv.cobblestone = (state.inv.cobblestone || 0) + 1;
}
}
sendBlockChange(mgx, mgy, removed.t, 'remove');
rebuildHotbar();
}
state.mining = { gx: null, gy: null, progress: 0, hardness: 0 };
miningHeld = false;
clearInterval(miningInterval);
miningInterval = null;
}
}
}, 100);
return;
}
// ====== BUILD MODE ======
if (mode() === 'build') {
// Planting seeds on farmland (works even without farmland in inventory)
const seedToCropBlock = {
wheat_seeds: 'wheat_stage0',
carrot: 'carrot_stage0',
potato: 'potato_stage0'
};
if (seedToCropBlock[state.selected] && state.inv[state.selected] > 0) {
// Clicking on farmland — plant above it
if (b && b.t === 'farmland') {
const aboveKey = gx + ',' + (gy - 1);
const above = state.grid.get(aboveKey);
if (!above || above.dead) {
setBlock(gx, gy - 1, seedToCropBlock[state.selected], true);
state.inv[state.selected]--;
const cropBlock = state.grid.get(gx + ',' + (gy - 1));
if (cropBlock) cropBlock.growthTimer = 0;
rebuildHotbar();
return;
}
}
// Clicking on empty space above farmland — plant there
if (!b) {
const belowKey = gx + ',' + (gy + 1);
const below = state.grid.get(belowKey);
if (below && below.t === 'farmland') {
setBlock(gx, gy, seedToCropBlock[state.selected], true);
state.inv[state.selected]--;
const cropBlock = state.grid.get(gx + ',' + gy);
if (cropBlock) cropBlock.growthTimer = 0;
rebuildHotbar();
return;
}
}
}
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);
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 === 'dirt' || state.selected === 'grass') playSound('cloth1');
rebuildHotbar();
return;
}
});
// Release mouse — stop hold-to-mine
canvas.addEventListener('pointerup', () => {
miningHeld = false;
});
canvas.addEventListener('pointerleave', () => {
miningHeld = false;
// Reset mining when mouse leaves canvas
});
}
// === 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 {
// SP: ускорение времени во время сна или замороженный цикл
if (!state.dayNightCycle) {
// Цикл день/ночь выключен — время стоит
} else if (player.sleeping && isNight()) {
state.worldTime += dt * 8 / state.DAY_LEN;
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);
if (!Number.isFinite(player.x) || !Number.isFinite(player.y)) {
var startGX = 6;
for (var sdx = -3; sdx <= 3; sdx++) genColumn(startGX + sdx);
player.x = startGX * state.TILE;
player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
player.vx = player.vy = 0;
state.camX = Math.floor((player.x + player.w / 2) - state.W / 2);
state.camY = Math.floor((player.y + player.h / 2) - state.H / 2);
console.warn('[Loop] player pos was broken, respawned at start');
}
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);
// кислород/утопление
if (player.headInWater) {
player.o2 = Math.max(0, player.o2 - 6 * dt);
if (player.o2 === 0) {
const damage = calculateDamage(4 * dt);
player.hp -= damage;
}
} else {
player.o2 = Math.min(100, player.o2 + 10 * dt);
}
// голод убывает
player.hunger = Math.max(0, player.hunger - dt * 0.2);
// ========== TEMPERATURE ==========
if (player.temperature === undefined) player.temperature = 15;
const BIOME_TEMP = { snow: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 };
const COLD_THRESHOLD = -5;
const HEAT_THRESHOLD = 35;
const HEAT_RADIUS = 5;
const pGX = Math.floor(player.x / state.TILE);
const biome = getCachedBiome(pGX);
const pGY = Math.floor(player.y / state.TILE);
const sGY = surfaceGyAt(pGX);
const isUndergroundTemp = (pGY - sGY) > 2;
let targetTemp = BIOME_TEMP[biome] || 15;
if (isUndergroundTemp) targetTemp = 15;
if (isNight() && !isUndergroundTemp) targetTemp -= 10;
if ((state.weatherType === 'rain' || state.weatherType === 'storm') && !isUndergroundTemp) targetTemp -= 5;
player.temperature += (targetTemp - player.temperature) * dt * 0.3;
let nearHeat = false, nearCool = false;
for (let dx = -HEAT_RADIUS; dx <= HEAT_RADIUS; dx++) {
for (let dy = -HEAT_RADIUS; dy <= HEAT_RADIUS; dy++) {
const b = getBlock(pGX + dx, pGY + dy);
if (!b || b.dead) continue;
if (b.t === 'campfire' || b.t === 'torch') nearHeat = true;
if (b.t === 'water' && biome === 'desert') nearCool = true;
}
}
if (nearHeat) {
player.temperature += (30 - player.temperature) * dt * 2;
if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt);
}
if (nearCool) {
player.temperature += (15 - player.temperature) * dt * 1.5;
if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) player.hp = Math.min(100, player.hp + 8 * dt);
}
if (isUndergroundTemp && !nearHeat) {
if (player.hp < 100 && player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) {
player.hp = Math.min(100, player.hp + 2 * dt);
}
}
const aboveBlk = getBlock(pGX, pGY - 1);
const inShade = aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass';
if (inShade && !isUndergroundTemp && !nearCool) {
player.temperature -= 5 * dt;
if (player.temperature > HEAT_THRESHOLD - 2 && player.hp < 100) player.hp = Math.min(100, player.hp + 1 * dt);
}
if (player.temperature < COLD_THRESHOLD && !isUndergroundTemp) {
const severity = Math.abs(player.temperature - COLD_THRESHOLD) / 15;
player.hp -= 1 * severity * dt;
if (severity > 0.5) player.vx *= (1 - 0.3 * Math.min(1, severity) * dt);
}
if (player.temperature > HEAT_THRESHOLD && !isUndergroundTemp) {
const severity = (player.temperature - HEAT_THRESHOLD) / 15;
player.hp -= 1 * severity * dt;
if (severity > 0.5) player.hunger -= 0.5 * severity * dt;
}
if (player.slowTimer > 0) player.slowTimer -= dt;
// Кактус — урон при касании
const pCGX = Math.floor((player.x + player.w / 2) / state.TILE);
const pCGY = Math.floor((player.y + player.h / 2) / state.TILE);
for (let cdx = -1; cdx <= 1; cdx++) {
for (let cdy = -1; cdy <= 1; cdy++) {
const cb = getBlock(pCGX + cdx, pCGY + cdy);
if (cb && !cb.dead && BLOCKS[cb.t] && BLOCKS[cb.t].hurt) {
if (player.invuln <= 0) {
player.hp -= 2;
player.invuln = 0.5;
}
}
}
}
// Лёд — скольжение
const iceBlock = getBlock(pCGX, pCGY + 1);
if (iceBlock && BLOCKS[iceBlock.t] && BLOCKS[iceBlock.t].slip && player.grounded) {
player.vx *= 0.98;
}
// Игрок не может двигаться во время сна
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);
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);
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++;
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 {
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) {
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);
}
}
// ====== CROP GROWTH ======
for (const [key, blk] of state.grid) {
if (blk.dead) continue;
const bDef = BLOCKS[blk.t];
if (bDef.growthStage === undefined) continue;
if (bDef.harvestable) continue; // stage 3 = fully grown
if (!blk.growthTimer && blk.growthTimer !== 0) blk.growthTimer = 0;
blk.growthTimer += dt;
// Growth time from block def (default 30s per stage)
const growTime = bDef.growthTime || 30;
if (blk.growthTimer >= growTime) {
blk.growthTimer = 0;
const nextStage = bDef.growsTo;
if (nextStage) {
blk.t = nextStage;
// Update texture reference
if (tex[nextStage]) {
// Block type changed — will render new texture next frame
}
}
}
}
// mobs spawn — only in singleplayer
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();
const spawnBiome = getCachedBiome(gx);
if (night) {
const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton' || m.kind === 'scorpion' || m.kind === 'slime').length;
if (hostileCount < 12) {
const rand = Math.random();
if (spawnBiome === 'desert' && rand < 0.6) {
mobs.push(new Scorpion(wx, wy));
} else if (spawnBiome === 'swamp' && rand < 0.5) {
mobs.push(new Slime(wx, wy));
} else 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));
}
}
}
// Daytime biome mobs
const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken' || m.kind === 'polar_bear' || m.kind === 'eagle').length;
if (animalCount < 8) {
if (spawnBiome === 'tundra' && Math.random() < 0.6) {
mobs.push(new PolarBear(wx, wy));
} else if (spawnBiome === 'mountains' && Math.random() < 0.4) {
mobs.push(new Eagle(wx, wy));
} else {
mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
}
}
}
}
// mobs update — только локальные
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') {
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
const playerGx = Math.floor(player.x / state.TILE); const skyBiome = getBiome(playerGx); ctx.fillStyle = skyColor(night, skyBiome, state.weatherType, state.worldTime);
ctx.fillRect(0, 0, W, H);
// clouds
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);
}
}
// ====== MINING CRACK OVERLAY ======
if (state.mining.gx !== null && state.mining.hardness > 0) {
const mg = state.mining;
const progress = Math.min(1, mg.progress / mg.hardness);
const cx = mg.gx * TILE - camX + camX; // already in world coords since we translated
const cy = mg.gy * TILE - camY + camY;
// Actually, we're in translated context, so just use world coords
const wx = mg.gx * TILE;
const wy = mg.gy * TILE;
ctx.save();
ctx.globalAlpha = Math.min(0.8, progress);
// Draw crack lines
const numCracks = Math.floor(progress * 5);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
for (let i = 0; i < numCracks; i++) {
ctx.beginPath();
ctx.moveTo(wx + TILE/2, wy + TILE/2);
const angle = (i * 72 + 36) * Math.PI / 180;
ctx.lineTo(wx + TILE/2 + Math.cos(angle) * TILE * 0.4, wy + TILE/2 + Math.sin(angle) * TILE * 0.4);
ctx.stroke();
}
// Darkening overlay
ctx.fillStyle = `rgba(0,0,0,${progress * 0.4})`;
ctx.fillRect(wx, wy, TILE, TILE);
// Progress bar under block
ctx.globalAlpha = 1;
ctx.fillStyle = '#333';
ctx.fillRect(wx, wy + TILE - 4, TILE, 4);
ctx.fillStyle = '#2ecc71';
ctx.fillRect(wx, wy + TILE - 4, TILE * progress, 4);
ctx.restore();
}
// 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') {
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') {
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.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') {
ctx.fillStyle = '#ECEFF1';
ctx.fillRect(m.x + 10, m.y + 20, 14, 12);
ctx.fillRect(m.x + 8, m.y + 0, 18, 18);
ctx.fillStyle = '#000';
ctx.fillRect(m.x + 10, m.y + 6, 4, 4);
ctx.fillRect(m.x + 20, m.y + 6, 4, 4);
ctx.fillRect(m.x + 15, m.y + 12, 4, 2);
ctx.fillStyle = '#ECEFF1';
ctx.fillRect(m.x + 2, m.y + 20, 6, 14);
ctx.fillRect(m.x + 26, m.y + 20, 6, 14);
ctx.fillRect(m.x + 10, m.y + 32, 6, 18);
ctx.fillRect(m.x + 18, m.y + 32, 6, 18);
ctx.save();
ctx.translate(m.x + 30, m.y + 22);
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, 8, -Math.PI * 0.7, Math.PI * 0.7);
ctx.stroke();
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(8 * Math.cos(-Math.PI * 0.7), 8 * Math.sin(-Math.PI * 0.7));
ctx.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7));
ctx.stroke();
ctx.restore();
} else if (m.kind === 'scorpion') {
// Scorpion: dark brown body, claws, tail
ctx.fillStyle = '#5D4037';
ctx.fillRect(m.x, m.y, m.w, m.h);
ctx.fillStyle = '#8D6E63';
ctx.fillRect(m.x + 2, m.y + 10, 6, 8); // left claw
ctx.fillRect(m.x + m.w - 8, m.y + 10, 6, 8); // right claw
// Tail
ctx.fillStyle = '#5D4037';
ctx.beginPath();
ctx.moveTo(m.x + m.w / 2, m.y);
ctx.lineTo(m.x + m.w / 2 - 3, m.y - 10);
ctx.lineTo(m.x + m.w / 2 + 3, m.y - 10);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#FF5722';
ctx.fillRect(m.x + m.w / 2 - 2, m.y - 12, 4, 4); // stinger
} else if (m.kind === 'polar_bear') {
// Polar bear: white body, black nose
ctx.fillStyle = '#ECEFF1';
ctx.fillRect(m.x, m.y, m.w, m.h);
ctx.fillStyle = '#263238';
ctx.fillRect(m.x + m.w / 2 - 2, m.y + 6, 4, 3); // nose
ctx.fillRect(m.x + 8, m.y + 12, 3, 3); // eye
ctx.fillRect(m.x + m.w - 11, m.y + 12, 3, 3); // eye
// legs
ctx.fillStyle = '#CFD8DC';
ctx.fillRect(m.x + 6, m.y + m.h - 8, 8, 8);
ctx.fillRect(m.x + m.w - 14, m.y + m.h - 8, 8, 8);
} else if (m.kind === 'slime') {
// Slime: green bouncy blob
ctx.fillStyle = '#4CAF50';
ctx.beginPath();
ctx.arc(m.x + m.w / 2, m.y + m.h / 2, m.w / 2, 0, Math.PI * 2);
ctx.fill();
// Eyes
ctx.fillStyle = '#fff';
ctx.fillRect(m.x + 6, m.y + 7, 5, 5);
ctx.fillRect(m.x + m.w - 11, m.y + 7, 5, 5);
ctx.fillStyle = '#000';
ctx.fillRect(m.x + 8, m.y + 9, 2, 2);
ctx.fillRect(m.x + m.w - 9, m.y + 9, 2, 2);
} else if (m.kind === 'eagle') {
// Eagle: brown body, wings, yellow beak
ctx.fillStyle = '#5D4037';
ctx.fillRect(m.x + m.w / 2 - 4, m.y + 6, 8, 14); // body
// Wings
ctx.fillStyle = '#795548';
ctx.fillRect(m.x, m.y + 8, m.w / 2 - 4, 6);
ctx.fillRect(m.x + m.w / 2 + 4, m.y + 8, m.w / 2 - 4, 6);
// Head
ctx.fillStyle = '#ECEFF1';
ctx.fillRect(m.x + m.w / 2 - 3, m.y + 2, 6, 6);
// Beak
ctx.fillStyle = '#FFC107';
ctx.fillRect(m.x + m.w / 2 + 3, m.y + 4, 4, 2);
// Eye
ctx.fillStyle = '#000';
ctx.fillRect(m.x + m.w / 2 - 1, m.y + 4, 2, 2);
}
}
// boat
if (boat.active) {
ctx.drawImage(tex['boat'], boat.x - (TILE - boat.w) / 2, boat.y - (TILE - boat.h) / 2, TILE, TILE);
}
// other players
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
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.97 + Math.sin(now / 300 + sx * 0.01) * 0.015 + Math.sin(now / 470 + sy * 0.02) * 0.015;
const r = radius * flick;
const steps2 = 24;
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.3; step < r; step += TILE * 0.35) {
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.9 + Math.sin(now / 350 + b.gx * 3.7) * 0.05 + Math.sin(now / 530 + b.gy * 2.3) * 0.05;
const wx = b.gx * TILE + TILE / 2 - camX;
const wy = b.gy * TILE + TILE / 2 - camY;
const r = def.lightRadius * 0.85;
const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r);
grad.addColorStop(0, `rgba(255,180,80,0.28)`);
grad.addColorStop(0.5, `rgba(255,140,40,0.13)`);
grad.addColorStop(1, 'rgba(255,100,20,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(wx, wy, r, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
// Дождь
drawWeather(ctx);
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);
// Temperature display
const tempEl = document.getElementById('temp');
if (tempEl) {
const t = Math.round(player.temperature);
tempEl.textContent = t + '°C';
if (player.temperature < COLD_THRESHOLD) tempEl.style.color = '#74b9ff';
else if (player.temperature > HEAT_THRESHOLD) tempEl.style.color = '#e17055';
else tempEl.style.color = '#55efc4';
}
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);
}
// Миникарта
if (state.minimapOpen && Math.random() < 0.25) {
renderMinimap();
}
requestAnimationFrame(loop);
}
// === ui/market.js ===
// ==================== РЫНОК ПРЕДЛОЖЕНИЙ ====================
let marketPanel, marketClose, marketContent;
function initMarket() {
marketPanel = document.getElementById('marketPanel');
marketClose = document.getElementById('marketClose');
marketContent = document.getElementById('marketContent');
if (!marketPanel) return;
if (marketClose) marketClose.addEventListener('click', () => closeMarket());
document.addEventListener('toggleMarket', () => toggleMarket());
}
function toggleMarket() {
if (!marketPanel) return;
if (marketPanel.style.display === 'none' || !marketPanel.style.display) {
openMarket();
} else {
closeMarket();
}
}
function closeMarket() {
if (marketPanel) marketPanel.style.display = 'none';
}
function openMarket() {
if (marketPanel) {
marketPanel.style.display = 'block';
renderMarket();
}
}
function renderMarket() {
if (!marketContent) return;
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 => '<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><span style="color:#ffd700;">' + (o.offer_item||'') + '</span> x' + (o.offer_qty||1) + ' <span style="color:#888;"> ⇄ </span><span style="color:#7ecfff;">' + (o.want_item||'') + '</span> x' + (o.want_qty||1) + '</div><div style="font-size:11px;color:#888;">' + (o.seller || 'Аноним') + '</div></div>').join('');
marketContent.innerHTML = html;
const btn = document.getElementById('createOrderBtn');
if (btn) btn.addEventListener('click', showCreateOrder);
});
}
function showCreateOrder() {
if (!marketContent) return;
const invKeys = Object.keys(state.inv || {});
const invOpts = invKeys.filter(k => (state.inv[k]||0) > 0).map(k => '<option value="'+k+'">'+k+' (x'+state.inv[k]+')</option>').join('');
marketContent.innerHTML = '<div style="padding:8px;"><h3 style="margin:0 0 10px;font-size:14px;">Выставить предмет</h3><label style="font-size:12px;color:#aaa;">Отдаю:</label><select id="offerItem" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;">'+invOpts+'</select><label style="font-size:12px;color:#aaa;">Хочу:</label><input id="wantItem" placeholder="Название предмета" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;" /><div style="display:flex;gap:8px;"><button id="submitOrder" style="flex:1;padding:8px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;">Выставить</button><button id="cancelOrder" style="flex:1;padding:8px;background:#555;color:#fff;border:none;border-radius:6px;cursor:pointer;">Отмена</button></div></div>';
const cancelBtn = document.getElementById('cancelOrder');
if (cancelBtn) cancelBtn.addEventListener('click', () => renderMarket());
const submitBtn = document.getElementById('submitOrder');
if (submitBtn) submitBtn.addEventListener('click', () => {
const offerItem = document.getElementById('offerItem');
const wantItem = document.getElementById('wantItem');
if (!offerItem || !wantItem) return;
const offer = offerItem.value;
const want = wantItem.value.trim();
if (!offer || !want) { alert('Выберите предмет и укажите что хотите получить'); return; }
if (state.socket) {
state.socket.emit('market_create', { offer_item: offer, offer_qty: 1, want_item: want, want_qty: 1 });
playSound('click');
setTimeout(renderMarket, 500);
}
});
}
// === ui/settings.js ===
// Настройки
let settingsPanel = null;
let dayNightToggle = null;
// Weather button IDs in order
const WEATHER_BTNS = ['weatherClear', 'weatherRain', 'weatherSnow', 'weatherThunder'];
const WEATHER_ACTIVE_BG = '#3498db';
const WEATHER_INACTIVE_BG = '#555';
function setWeatherButtonActive(activeId) {
for (const id of WEATHER_BTNS) {
const btn = document.getElementById(id);
if (btn) {
btn.style.background = (id === activeId) ? WEATHER_ACTIVE_BG : WEATHER_INACTIVE_BG;
}
}
}
function initSettings() {
settingsPanel = document.getElementById('settingsPanel');
dayNightToggle = document.getElementById('dayNightToggle');
if (!settingsPanel) return;
const closeBtn = document.getElementById('settingsClose');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
settingsPanel.style.display = 'none';
});
}
// Day/night cycle toggle
if (dayNightToggle) {
dayNightToggle.checked = state.dayNightCycle !== false;
dayNightToggle.addEventListener('change', () => {
state.dayNightCycle = dayNightToggle.checked;
});
}
// Time-of-day slider
const timeSlider = document.getElementById('timeSlider');
if (timeSlider) {
timeSlider.value = Math.round(state.worldTime * 100);
timeSlider.addEventListener('input', () => {
if (state.isMultiplayer) return;
state.worldTime = parseInt(timeSlider.value) / 100;
if (state.dayNightCycle) {
state.dayNightCycle = false;
if (dayNightToggle) dayNightToggle.checked = false;
}
});
}
// ====== Weather controls (Ясно / Дождь / Снег / Гроза) ======
const weatherIntensitySlider = document.getElementById('weatherIntensity');
// Helper: apply a weather type
function applyWeather(type) {
if (state.isMultiplayer) return; // Погода от сервера в MP
state.weatherType = type;
state.weatherChangeInterval = Infinity; // Lock weather manually
switch (type) {
case 'clear':
state.isRaining = false;
state.rainIntensity = 0;
if (weatherIntensitySlider) weatherIntensitySlider.value = 0;
break;
case 'rain':
state.isRaining = true;
state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100;
break;
case 'snow':
state.isRaining = false; // snow uses its own rendering
state.rainIntensity = parseInt(weatherIntensitySlider?.value || 40) / 100;
break;
case 'thunder':
state.isRaining = true; // thunder includes rain
state.rainIntensity = parseInt(weatherIntensitySlider?.value || 60) / 100;
if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100);
break;
}
setWeatherButtonActive('weather' + type.charAt(0).toUpperCase() + type.slice(1));
}
// Button handlers
for (const id of WEATHER_BTNS) {
const btn = document.getElementById(id);
if (btn) {
const weatherType = btn.dataset.weather || id.replace('weather', '').toLowerCase();
btn.addEventListener('click', () => applyWeather(weatherType));
}
}
// Intensity slider
if (weatherIntensitySlider) {
weatherIntensitySlider.addEventListener('input', () => {
const val = parseInt(weatherIntensitySlider.value) / 100;
if (state.weatherType !== 'clear') {
state.rainIntensity = val;
if (state.weatherType === 'thunder' || state.weatherType === 'rain') {
state.isRaining = true;
}
}
});
}
// Listen for toggleSettings custom event
document.addEventListener('toggleSettings', () => {
if (settingsPanel) {
// Sync all controls when panel opens
if (timeSlider) timeSlider.value = Math.round(state.worldTime * 100);
if (dayNightToggle) dayNightToggle.checked = state.dayNightCycle !== false;
if (weatherIntensitySlider) weatherIntensitySlider.value = Math.round(state.rainIntensity * 100);
// Highlight active weather button
const wt = state.weatherType || (state.isRaining ? 'rain' : 'clear');
const activeId = 'weather' + wt.charAt(0).toUpperCase() + wt.slice(1);
setWeatherButtonActive(activeId);
settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none';
}
});
}
function isDayNightEnabled() {
return state.dayNightCycle !== false;
}
// === 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 ===');
};
}
// === 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;
// Кнопка микрофона
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:10px;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 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: state.worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' });
});
voiceSocket.on('voice_in', (payload) => {
// Воспроизводим входящий голос
const { data, meta, volume: rawVolume } = payload;
const volume = rawVolume !== undefined ? rawVolume : 1.0;
if (!audioCtx || audioCtx.state === 'closed') return;
// Int16 → Float32 (volume applied via gain node, not in conversion)
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);
}
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/craft.js ===
// Craft UI
function canCraft(r) {
const inv = state.inv;
for (const res in r.cost) {
if ((inv[res] || 0) < r.cost[res]) 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';
// Иконка результата — canvas sprite > emoji
if (tex[r.out]) {
icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
} else if (itemTex[r.out]) {
icon.style.backgroundImage = `url(${itemTex[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);
if (!state.recentItems.includes(r.out)) { state.recentItems.unshift(r.out); state.recentItems = state.recentItems.slice(0, 5); }
rebuildHotbar();
renderCraft();
renderInventory();
};
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 ====================
// urlParams defined in config.js
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 = new Image(); 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;
// ==================== 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();
initSettings();
initModes();
// ==================== 10) SOCKET — initSocket ====================
initSocket();
// ==================== 11) VOICE — initVoice ====================
initVoice();
// ==================== 12) SAVE — loadGame, applySave ====================
// Ensure hotbar has items on first load
if (state.recentItems.length === 0) {
state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5);
}
// Make sure selected item is valid and in recentItems
if (!state.selected || !state.inv[state.selected] || state.inv[state.selected] <= 0) {
state.selected = state.recentItems[0] || 'dirt';
}
if (!state.recentItems.includes(state.selected)) {
state.recentItems.unshift(state.selected);
state.recentItems = state.recentItems.slice(0, 5);
}
rebuildHotbar();
// Keyboard hotbar switching (1/2/3)
document.addEventListener('keydown', (e) => {
if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return;
if (e.key >= '1' && e.key <= '3') {
const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3);
const idx = parseInt(e.key) - 1;
if (items[idx]) {
state.selected = items[idx];
rebuildHotbar();
}
}
});
// Scroll wheel hotbar cycling
document.addEventListener('wheel', (e) => {
if (state.showFullInventory || state.craftOpen || state.inventoryOpen || state.chatOpen) return;
const items = state.recentItems.filter(id => state.inv[id] > 0).slice(0, 3);
if (items.length === 0) return;
let idx = items.indexOf(state.selected);
if (idx < 0) idx = 0;
if (e.deltaY > 0) idx = (idx + 1) % items.length;
else idx = (idx - 1 + items.length) % items.length;
state.selected = items[idx];
rebuildHotbar();
e.preventDefault();
}, { passive: false });
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('Сохранение не найдено, начинаем новую игру');
// Fill recentItems from inventory
state.recentItems = Object.keys(state.inv).filter(k => state.inv[k] > 0).slice(0, 5);
rebuildHotbar();
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 && !state.isMultiplayer) {
saveGame();
}
});
// Автосейв перед закрытием страницы
window.addEventListener('beforeunload', () => {
if (!state.isMultiplayer) {
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);
// ==================== 14) WORLD-GEN PLACEHOLDER ====================
// 2D-мир генерируется лениво, колонками вокруг игрока. В первые секунды
// на экране только небо и HUD — пользователь считает это поломкой.
installWorldGenPlaceholder();
}
// ==================== WORLD-GEN PLACEHOLDER ====================
var _wgPh = null;
function worldHasVisibleBlocks() {
try {
var gx = Math.floor((state.player && state.player.x || 0) / state.TILE);
for (var dx = -3; dx <= 3; dx++) {
for (var up = 0; up < 30; up++) {
var b = getBlock(gx + dx, up);
if (b && b.t && b.t !== 'air' && b.t !== 'water' && b.t !== 'swamp_water') return true;
}
}
} catch (e) { /* getBlock может быть недоступен на ранних кадрах */ }
return false;
}
function installWorldGenPlaceholder() {
if (_wgPh || document.getElementById('wgPh')) return;
hideBoot();
var el = document.createElement('div');
el.id = 'wgPh';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.innerHTML =
'<div class="wgPh-box">' +
'<div class="wgPh-title">Генерация мира</div>' +
'<div class="wgPh-bar"><i></i></div>' +
'<div class="wgPh-hint">Первые секунды мир строится по колонкам</div>' +
'</div>';
document.body.appendChild(el);
_wgPh = el;
var st = document.createElement('style');
st.textContent = [
'#wgPh{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;',
'justify-content:center;background:linear-gradient(#0f1720,#1c2b3a);',
'font:14px/1.4 system-ui,-apple-system,sans-serif;color:#e8f0f8}',
'#wgPh.gone{display:none}',
'.wgPh-box{text-align:center;max-width:320px;padding:0 24px}',
'.wgPh-title{font-size:18px;margin-bottom:14px}',
'.wgPh-bar{height:6px;background:rgba(255,255,255,.12);border-radius:3px;overflow:hidden}',
'.wgPh-bar>i{display:block;height:100%;width:35%;background:#5a8f3c;border-radius:3px;',
'animation:wgPhSlide 1.1s ease-in-out infinite}',
'@keyframes wgPhSlide{0%{margin-left:-35%}100%{margin-left:100%}}',
'.wgPh-hint{margin-top:12px;color:#8fa3b8;font-size:12px}'
].join('');
document.head.appendChild(st);
var deadline = Date.now() + 60000;
var born = Date.now();
var tick = function() {
if (!_wgPh || !_wgPh.isConnected) return;
if (Date.now() > deadline) {
el.remove();
return;
}
if (!worldHasVisibleBlocks() || Date.now() - born < 600) el.classList.remove('gone');
else el.classList.add('gone');
setTimeout(tick, 200);
};
setTimeout(tick, 150);
}
// Start the game
startGame().catch(err => {
console.error('Fatal error starting game:', err);
});
// Debug: give items
window.debugGive = function(id, count) {
state.inv[id] = (state.inv[id] || 0) + (count || 1);
if (!state.recentItems.includes(id)) { state.recentItems.unshift(id); state.recentItems = state.recentItems.slice(0, 5); }
rebuildHotbar();
// Can't call renderInventory from here, but user can click inv
console.log('Gave', count || 1, id, '=>', state.inv[id]);
console.log('inv keys>0:', Object.keys(state.inv).filter(k => state.inv[k] > 0).join(', '));
};
window.debugInv = function() { return Object.keys(state.inv).filter(k => state.inv[k] > 0).map(k => k + ':' + state.inv[k]); };
})();