// ==================== 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]); };