// ==================== ENTRY POINT ==================== import { state } from './core/state.js'; import { BLOCKS } from './data/blocks.js'; import { SERVER_URL } from './config.js'; import { showStartMenu, isTgWebApp, tgUser } from './ui/start-menu.js'; import { initCanvas } from './core/canvas.js'; import { initSocket } from './multiplayer/socket.js'; import { loadSound, playSound } from './audio/sound-engine.js'; import { initTextures, tex } from './render/textures.js'; import { getBlock, setBlock, removeBlock } from './world/world-storage3d.js'; import { genColumn, surfaceGyAt, getCachedBiome, resetGenerationCache, isWorldReady, worldGenProgress } from './world/generation3d.js'; import { initDB, loadGame, applySave, saveGame } from './game/save.js'; import { loop } from './game/loop3d.js'; import { initChat } from './ui/chat.js'; import { initFurnace } from './ui/furnace.js'; import { initMinimap } from './ui/minimap.js'; import { initSaveControls, updateSaveButtonVisibility } from './ui/save-controls.js'; import { initMarket } from './ui/market.js'; import { initSettings } from './ui/settings.js'; import { initRespawn } from './ui/respawn.js'; import { initShare } from './ui/share.js'; import { initControls } from './input/controls3d.js'; import { initJoystick } from './input/joystick3d.js'; import { initModes } from './game/modes.js'; import { initVoice } from './multiplayer/voice-chat.js'; import { rebuildHotbar } from './ui/hotbar.js'; import { toggleCraft, closeCraft, toggleInventory, closeInventory } from './ui/craft.js'; import { fluidAt } from './physics/collision3d.js'; import { ensurePlayerOnGround, findStandSpot } from './spawn3d.js'; dismissBootScreen(); // ==================== 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'; // Диаг-доступ только по ?diag=1 — в обычной игре не включается if (/[?&]diag=1/.test(location.search)) window.__st = state; // ==================== 2) START MENU ==================== // Show the start menu before initializing the game. // The menu returns a Promise that resolves with the worldId. // ==================== ЭКРАН ЗАГРУЗКИ МИРА ==================== // Мир 96x96x45 ≈ 420k блоков. Он строится порциями в игровом цикле, пока // пользователь смотрит на прогресс. Раньше экран ждал молча, и вход выглядел // как зависший. function showWorldLoader() { let el = document.getElementById('v3load'); if (!el) { el = document.createElement('div'); el.id = 'v3load'; el.style.cssText = [ 'position:fixed', 'inset:0', 'z-index:2147483000', 'display:flex', 'flex-direction:column', 'align-items:center', 'justify-content:center', 'background:linear-gradient(180deg,#0d1b3e 0%,#1b3a6b 42%,#3f6fa8 68%,#8fc7f5 100%)', 'font:600 15px/1.4 system-ui,-apple-system,Segoe UI,sans-serif', 'color:#eaf3ff', 'text-align:center', 'padding:24px', 'overflow:hidden' ].join(';'); // Фон: солнце, дымящиеся холмы, бегущие облака. Всё на CSS — грузится // мгновенно и не ждёт ассетов, поэтому экран появляется без белой вспышки. el.innerHTML = '
' + // солнце '
' + // облака '
' + '
' + // холмы '
' + '
' + // гречка — колос: стебель + 6 зёрен вокруг него. Раньше зёрна строились // отрицательным margin от пустого блока и висели над землёй точками. '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
Гречка Craft 3D
' + '
Подготовка…
' + '
' + '
' + '
0%
' + '
' + 'Строим мир: рельеф, биомы, деревья, руды и гречка
' + '
'; document.body.appendChild(el); } el.style.display = 'flex'; return el; } function hideWorldLoader() { const el = document.getElementById('v3load'); if (el) el.style.display = 'none'; } // Статичная заставка из index-v2.html живёт, пока грузится JS-бандл. Гасим её // сразу, как только скрипт стартует: дальше свою работу делает экран генерации // мира с реальным прогрессом. function dismissBootScreen() { const b = document.getElementById('v3boot'); if (!b) return; if (typeof window.__v3bootDone === 'function') window.__v3bootDone(); b.style.transition = 'opacity .3s ease'; b.style.opacity = '0'; setTimeout(() => { b.style.display = 'none'; }, 320); } // Ждём готовности мира, обновляя полосу прогресса. function waitWorldReady(maxMs = 180000) { const el = showWorldLoader(); const fill = document.getElementById('v3loadfill'); const txt = document.getElementById('v3loadtxt'); const stage = document.getElementById('v3loadstage'); const hint = document.getElementById('v3loadhint'); // Этапы по доле готовности. Один текст «Готовим мир…» не объяснял, что // происходит, и загрузка выглядела как зависание. const STAGES = [ [0.02, 'Расстилаем рельеф', 'Режем холмы и долины'], [0.18, 'Раздаём биомы', 'Луга, лес, песок и снег'], [0.42, 'Заросли и деревья', 'Сажаем лес и кусты'], [0.66, 'Закладываем руды', 'Уголь, железо, золото'], [0.84, 'Сеем гречку', 'Главный урожай'], [0.97, 'Ставим игрока', 'Ищем сухой берег'] ]; return new Promise(resolve => { const t0 = performance.now(); const tick = () => { const pr = isWorldReady() ? 1 : worldGenProgress(); const pct = Math.round(pr * 100); if (fill) fill.style.width = pct + '%'; if (txt) txt.textContent = pct + '%'; if (stage) { let cur = STAGES[0]; for (const st of STAGES) if (pr >= st[0]) cur = st; if (stage.textContent !== cur[1]) stage.textContent = cur[1]; if (hint) hint.textContent = cur[2]; } if (isWorldReady() || performance.now() - t0 > maxMs) { const ok = isWorldReady(); if (fill) fill.style.width = '100%'; if (txt) txt.textContent = '100%'; if (stage) stage.textContent = ok ? 'Мир готов!' : 'Погружаем…'; if (hint) hint.textContent = ok ? 'Приятной игры' : 'Мир не успел — заходим как есть'; // Короткая пауза, чтобы «Мир готов!» успел прочитать, и плавный уход. setTimeout(() => { if (el) el.style.transition = 'opacity .35s ease'; if (el) el.style.opacity = '0'; setTimeout(hideWorldLoader, 360); resolve(isWorldReady()); }, ok ? 420 : 120); return; } requestAnimationFrame(tick); }; tick(); }); } // Гарантируем, что игрок стоит на твёрдом блоке. Спавн вычисляется ДО генерации // мира, поэтому опора под ногами часто не успевает построиться — игрок падал // до bedrock (Y=132) и висел в пустоте. После готовности мира проверяем и // ставим на верхний твёрдый блок своей колонки. // ---------- СПАВН: findStandSpot ---------- // Критерии пригодного места (все обязательны): // 1. сверху колонки есть твёрдый блок — на него ставим ноги; // 2. над ним 2 свободных тайла — игрок и запас на прыжок; // 3. нет воды в радиусе 3 тайла и выше поверхности; // 4. в тайле над головой (top-1) нет твёрдого блока — иначе игрок // окажется в пещере под блоком; // 5. блок под опорой тоже твёрдый (не «парящая» трава над пещерой). // Поиск идёт по расширяющейся спирали, а не по одной колонке: раньше // игрока ставило в воду или в толщу снега, потому что подходил только // собственный тайл. 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(); initJoystick(); // ==================== 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; // spawnPoint.y копируется из player.y (см. save.js:130), то есть это // ВЕРХ коллайдера — вычитать высоту нельзя. 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; // Старт — сухая суша в центре мира. Перебираем ближайшие колонки и берём // первую без воды/болота, чтобы не начинать в трясине. let startGX = 6, startGZ = 0, startGY = 0, found = false; const PREF_BIOMES = ['plains', 'desert']; // сухая, безопасная земля outer: for (let r = 0; r <= 60 && !found; r++) { for (let dz = -r; dz <= r; dz++) { for (let dx = -r; dx <= r; dx++) { if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; // только кольцо const gx = 6 + dx, gz = dz; if (Math.abs(gz) > 40) continue; // раньше 18 — при WORLD_DEPTH=48 искал в узкой полосе и не находил суши const b = getCachedBiome(gx, gz); if (!PREF_BIOMES.includes(b)) continue; // Условие «sy > SEA_GY + 3» отбрасывало ВСЕ колонки: в этом мире поверхность // лежит на gy 8..14, SEA_GY = 14, то есть требование sy > 17 не // выполнялось НИГДЕ — игрока всегда выносило в fallback, и тот // ставил его в болото. Теперь проверяем настоящую колонку. genColumn(gx, gz); const pSolid = (px, py, pz) => { const bb = getBlock(px, py, pz); if (!bb) return false; const dd = BLOCKS[bb.t]; return !!(dd && dd.solid && !dd.fluid && !dd.decor && !dd.climbable); }; const pFluid = (px, py, pz) => { const bb = getBlock(px, py, pz); return !!(bb && BLOCKS[bb.t] && BLOCKS[bb.t].fluid); }; let ptop = -1; for (let gy = Math.min(state.BEDROCK_GY + 2, 220); gy >= 1; gy--) { if (pSolid(gx, gy, gz)) { ptop = gy; break; } } if (ptop < 1) continue; // нет опоры if (pFluid(gx, ptop - 1, gz)) continue; // лужа под ногами let near = false; for (let dx = -3; dx <= 3 && !near; dx++) for (let dz = -3; dz <= 3 && !near; dz++) for (let gy = Math.min(state.SEA_GY, 220); gy > ptop; gy--) if (pFluid(gx + dx, gy, gz + dz)) { near = true; break; } if (near) continue; // вокруг вода const sy = ptop; startGX = gx; startGZ = gz; startGY = sy; found = true; // sy = блок-опора break outer; } } } if (!found) { // Суши по биому не нашлось — ищем любую колонку выше уровня моря. let bestGY = -1, bestX = 6, bestZ = 0; for (let r = 0; r <= 40; r++) { for (let dz = -r; dz <= r; dz++) { for (let dx = -r; dx <= r; dx++) { if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; const gx = 6 + dx, gz = dz; if (Math.abs(gz) > 40) continue; // Тот же дефект, что и в основной ветке: SEA_GY + 2 = 16, // а поверхность в мире 8..14, поэтому условие не выполнялось // НИГДЕ и fallback тоже ничего не находил. const sy = surfaceGyAt(gx, gz); if (sy < state.SEA_GY - 4 && sy > bestGY) { bestGY = sy; bestX = gx; bestZ = gz; } } } if (bestGY > 0) break; } if (bestGY > 0) { startGX = bestX; startGZ = bestZ; startGY = bestGY; found = true; } } if (!found) { startGX = 6; startGZ = 0; startGY = surfaceGyAt(6, 0) + 2; } state.player.x = startGX * state.TILE; state.player.z = startGZ * state.TILE; state.player.vx = state.player.vy = state.player.vz = 0; // Предгенерация вокруг спавна. Радиус по X был 4 тайла, по Z 20 — при // этом по Z уже генерировалось до 24, а по X мир вырождался в полосу // шириной 9 тайлов (замер: world x от 2 до 10 при игроке на 6). for (let dx = -48; dx <= 48; dx++) { for (let dz = -48; dz <= 48; dz++) { if (dx * dx + dz * dz > 48 * 48) continue; genColumn(startGX + dx, startGZ + dz); } } // после генерации ищем сухую опору: идём вверх, пока под ноздрями вода/болото // Ищем тайл ТВЁРДОГО блока-опоры: самый верхний твёрдый блок колонки, // над которым не вода. Раньше startGY был на единицу ниже, и игрок // оказывался ВНУТРИ блока (утопание) или в небе над ним. { let topGY = -1; for (let gy = 1; gy < 200; gy++) { const bl = getBlock(startGX, gy, startGZ); if (!bl || bl.dead) break; const def = BLOCKS[bl.t]; if (!def || !def.solid || def.fluid || def.decor || def.climbable) break; topGY = gy; } if (topGY > 0) startGY = topGY; else startGY = Math.max(surfaceGyAt(startGX, startGZ), state.SEA_GY); } // ВАЖНО: player.y — ВЕРХ коллайдера, ноги = y + h. // Стоять на блоке startGY => y = startGY * TILE - h. // зазор 0.5px, иначе первый шаг вверх гасит импульс прыжка // Ось Y направлена ВНИЗ. Верхняя грань блока startGY = startGY*TILE, // ноги = y + h => y = startGY * TILE - h. // Опора под ногами ОБЯЗАТЕЛЬНА. Если блока нет — ставим его, иначе игрок // проваливается до bedrock (проверено: Y уходил в 132 при BEDROCK_GY=140). { const footBlk = getBlock(startGX, startGY, startGZ); const footDef = footBlk ? BLOCKS[footBlk.t] : null; const solid = !!(footDef && footDef.solid && !footDef.fluid && !footDef.decor); if (!solid) { setBlock(startGX, startGY, startGZ, 'grass', true); } else if (startGY > 1) { // проверяем, что это и правда верхняя точка опоры const above = getBlock(startGX, startGY - 1, startGZ); if (above) { const ad = BLOCKS[above.t]; if (!ad || !ad.solid || ad.fluid || ad.decor) removeBlock(startGX, startGY - 1, startGZ); } } } // Ось Y вниз: верхняя грань блока startGY = startGY*TILE, значит // feet = startGY*TILE и p.y = startGY*TILE - h. state.player.y = startGY * state.TILE - state.player.h; state.player.fallStartY = state.player.y; state.player.grounded = true; state.spawnPoint.x = state.player.x; state.spawnPoint.y = state.player.y; state.spawnPoint.z = state.player.z; console.log('Новая 3D игра: gx=', startGX, 'gz=', startGZ, 'biome=', getCachedBiome(startGX, startGZ), 'gy=', startGY, 'y=', state.player.y); } // Автосейв при скрытии страницы document.addEventListener('visibilitychange', () => { if (document.hidden) { saveGame(); } }); // Автосейв перед закрытием страницы window.addEventListener('beforeunload', () => { saveGame(); }); }).catch(err => { console.error('Ошибка инициализации:', err); // Аварийный старт: только колонка игрока, остальное достроит pumpWorldGen // из игрового цикла. Раньше здесь генерировалась узкая полоса 9x40, из-за // чего мир выглядел островом. const startGX = 6, startGZ = 0; genColumn(startGX, startGZ); state.player.y = surfaceGyAt(startGX, startGZ) * state.TILE - state.player.h; state.player.fallStartY = state.player.y; }); // ==================== 13) LOOP — startLoop ==================== // Мир строится порциями в цикле, поэтому сначала запускаем цикл, затем // ждём готовности с прогрессом на экране. requestAnimationFrame(loop); // Ставим игрока на поверхность ДО генерации мира, чтобы он не начал // падать в пустоту, и повторяем после готовности. ensurePlayerOnGround(); await waitWorldReady(); ensurePlayerOnGround(); // После фикса игрок может стоять в блоке, если гравитация успела осесть. // Даём физике несколько кадров и закрепляем ещё раз. await new Promise(r => setTimeout(r, 120)); ensurePlayerOnGround(); } // Start the game (v3 — 3D) 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]); };