// 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 = '
'; const box = document.createElement('div'); box.className = 'mc-box'; // Title bar const titleBar = document.createElement('div'); titleBar.className = 'mc-titlebar'; titleBar.innerHTML = '
⛏️ GrechkaCraft
' + '
' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '
'; 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 = '🔗 Твой ник: ' + data.nickname + ''; 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 + ' ' + f.nickname + ''; 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 => `
${m.time} ${m.sender}: ${m.message}
` ).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 = `
🤝 ${data.from_name} хочет торговать
`; 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 = `
🤝 Торговля с ${partnerName}
Вы предлагаете:
⇄
Партнёр предлагает:
Ожидание...
⏳ Партнёр ещё не подтвердил
`; 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 = 'Ничего'; } else { myItems.innerHTML = keys.map(k => `
${itemIcon(k)} ${itemName(k)} x${myOffer[k]}
`).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 = '
Что предложить?
'; for (const id of items) { // Show already offered count const offered = myOffer[id] || 0; const available = inv[id] - offered; if (available <= 0) continue; html += `
${itemIcon(id)} ${itemName(id)} x${available}
`; } html += '
'; 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 = '
'; // Доступные рецепты — показываем только те, для которых есть ресурсы 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 += `
`; html += `
${iconStr}
`; html += `
`; html += `
${nameStr}
`; html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`; html += `
`; html += ``; html += `
`; } // Текущий прогресс if (active) { const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100)); html += `
`; html += `
🔥 Обжиг: ${pct}%
`; html += `
`; html += `
`; html += `
`; } html += '
'; 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 = '

Подключение к серверу...

'; return; } state.socket.emit('market_list', {}, (orders) => { if (!orders || !orders.length) { marketContent.innerHTML = '

Предложений пока нет

'; const btn = document.getElementById('createOrderBtn'); if (btn) btn.addEventListener('click', showCreateOrder); return; } let html = '
'; html += orders.map(o => '
' + (o.offer_item||'') + ' x' + (o.offer_qty||1) + ' ⇄ ' + (o.want_item||'') + ' x' + (o.want_qty||1) + '
' + (o.seller || 'Аноним') + '
').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 => '').join(''); marketContent.innerHTML = '

Выставить предмет

'; 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 = '🎤/'; 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 = '🎤/'; 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 = '
' + '
Генерация мира
' + '
' + '
Первые секунды мир строится по колонкам
' + '
'; 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]); }; })();