grechka-game/srcv3/main3d.js

567 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// ==================== 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 =
'<div style="position:absolute;inset:0;overflow:hidden;pointer-events:none">' +
// солнце
'<div style="position:absolute;left:50%;top:26%;width:170px;height:170px;margin:-85px 0 0 -85px;' +
'border-radius:50%;background:radial-gradient(circle,#fff6d8 0%,#ffe08a 42%,rgba(255,214,120,0) 72%);' +
'filter:blur(2px);animation:v3sun 6s ease-in-out infinite"></div>' +
// облака
'<div style="position:absolute;top:14%;left:-30%;width:52%;height:40px;border-radius:50%;' +
'background:radial-gradient(ellipse,#ffffff8a 0%,#ffffff3d 56%,rgba(255,255,255,0) 78%);' +
'filter:blur(3px);animation:v3cloud 40s linear infinite"></div>' +
'<div style="position:absolute;top:25%;left:-40%;width:38%;height:26px;border-radius:50%;' +
'background:radial-gradient(ellipse,#ffffff70 0%,#ffffff30 56%,rgba(255,255,255,0) 78%);' +
'filter:blur(3px);animation:v3cloud 58s linear infinite"></div>' +
// холмы
'<div style="position:absolute;left:-10%;bottom:0;width:120%;height:38%;border-radius:50% 50% 0 0;' +
'background:linear-gradient(180deg,#2f7a3d,#1d5427)"></div>' +
'<div style="position:absolute;left:6%;bottom:0;width:80%;height:26%;border-radius:50% 50% 0 0;' +
'background:linear-gradient(180deg,#3a8f47,#256030)"></div>' +
// гречка — колос: стебель + 6 зёрен вокруг него. Раньше зёрна строились
// отрицательным margin от пустого блока и висели над землёй точками.
'<div style="position:absolute;left:50%;bottom:15%;margin-left:-11px;' +
'width:22px;height:56px;animation:v3bob 2.4s ease-in-out infinite">' +
'<div style="position:absolute;left:9px;bottom:0;width:4px;height:46px;background:#7dbf4a;border-radius:2px 2px 0 0"></div>' +
'<div style="position:absolute;left:1px;bottom:38px;width:10px;height:10px;background:#e8c95c;border-radius:50% 50% 50% 0;transform:rotate(-45deg)"></div>' +
'<div style="position:absolute;left:12px;bottom:38px;width:10px;height:10px;background:#e8c95c;border-radius:50% 50% 0 50%;transform:rotate(45deg)"></div>' +
'<div style="position:absolute;left:3px;bottom:29px;width:9px;height:9px;background:#dcbb4e;border-radius:50% 50% 50% 0;transform:rotate(-45deg)"></div>' +
'<div style="position:absolute;left:11px;bottom:29px;width:9px;height:9px;background:#dcbb4e;border-radius:50% 50% 0 50%;transform:rotate(45deg)"></div>' +
'<div style="position:absolute;left:4px;bottom:20px;width:8px;height:8px;background:#cfae42;border-radius:50% 50% 50% 0;transform:rotate(-45deg)"></div>' +
'<div style="position:absolute;left:11px;bottom:20px;width:8px;height:8px;background:#cfae42;border-radius:50% 50% 0 50%;transform:rotate(45deg)"></div>' +
'<div style="position:absolute;left:7px;bottom:44px;width:9px;height:11px;background:#dfc163;border-radius:50% 50% 45% 45%"></div>' +
'</div>' +
'<div style="width:8px;height:8px;margin:-38px -8px 0 0;background:#dcbb4e;border-radius:50% 50% 50% 0;transform:rotate(-40deg)"></div>' +
'<div style="width:8px;height:8px;margin:-32px 0 0 0;background:#dcbb4e;border-radius:50% 50% 0 50%;transform:rotate(40deg)"></div>' +
'<div style="width:7px;height:7px;margin:-26px -7px 0 0;background:#cfae42;border-radius:50% 50% 50% 0;transform:rotate(-40deg)"></div>' +
'<div style="width:7px;height:7px;margin:-20px 0 0 0;background:#cfae42;border-radius:50% 50% 0 50%;transform:rotate(40deg)"></div></div>' +
'</div>' +
'<style>' +
'@keyframes v3sun{0%,100%{transform:scale(1);opacity:.9}50%{transform:scale(1.08);opacity:1}}' +
'@keyframes v3cloud{from{transform:translateX(0)}to{transform:translateX(280%)}}' +
'@keyframes v3bob{0%,100%{transform:translate(-50%,0)}50%{transform:translate(-50%,-10px)}}' +
'@keyframes v3spin{to{transform:rotate(360deg)}}' +
'</style>' +
'<div style="position:relative;display:flex;flex-direction:column;align-items:center;gap:16px">' +
'<div style="font-size:clamp(22px,5vw,34px);font-weight:800;letter-spacing:.5px;' +
'text-shadow:0 3px 14px #0007">Гречка Craft 3D</div>' +
'<div id="v3loadstage" style="font-size:13px;opacity:.82;min-height:19px">Подготовка…</div>' +
'<div id="v3loadbar" style="width:min(330px,74vw);height:12px;border-radius:8px;' +
'background:#ffffff2b;overflow:hidden;box-shadow:inset 0 1px 3px #0005,0 6px 18px #0004">' +
'<div id="v3loadfill" style="width:0%;height:100%;border-radius:8px;' +
'background:linear-gradient(90deg,#7ee08a,#2ecc71);' +
'box-shadow:0 0 12px #7ee08aaa;transition:width .18s ease-out"></div></div>' +
'<div id="v3loadtxt" style="font-size:13px;opacity:.8;font-variant-numeric:tabular-nums">0%</div>' +
'<div id="v3loadhint" style="font-size:12px;opacity:.55;max-width:min(420px,86vw)">' +
'Строим мир: рельеф, биомы, деревья, руды и гречка</div>' +
'</div>';
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]); };