567 lines
30 KiB
JavaScript
567 lines
30 KiB
JavaScript
// ==================== 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]); };
|