// ==================== ЦИКЛ ИГРЫ 3D — GrechkaCraft v3 ==================== // Всё как в 2D-игре, но движение по трём осям: X (вперёд-назад по миру), // Y (вверх-вниз), Z (вбок). WASD работают относительно взгляда камеры. import { state } from '../core/state.js'; import { BLOCKS } from '../data/blocks.js'; import { playSound } from '../audio/sound-engine.js'; import { getBlock, isSolid } from '../world/world-storage3d.js'; import { updateWaterFlag3 } from '../physics/water-detect3d.js'; import { resolveY, resolveX, resolveZ, onLadder } from '../physics/collision3d.js'; import { calculateDamage } from '../entities/player.js'; import { tickFurnaces } from '../ui/furnace.js'; import { renderFurnaceUI } from '../ui/furnace.js'; import { updateWeather, updateRain } from '../world/weather.js'; import { ensureGenAroundCamera, isWorldReady, worldGenProgress, surfaceGyAt, getCachedBiome, WORLD_DEPTH } from '../world/generation3d.js'; import { Zombie, Creeper, Skeleton, Pig, Chicken, Scorpion, PolarBear, Slime, Eagle } from '../entities/mobs3d.js'; import { isNight, mobAI } from '../entities/mob-ai3d.js'; import { sendPlayerPosition } from '../multiplayer/socket-helpers.js'; import { renderFrame3D } from '../render/render3d.js'; import { explodeTNT3D } from '../world/tnt3d.js'; let last = performance.now(); let prevJump = false; document.addEventListener('visibilitychange', () => { if (!document.hidden) last = performance.now(); }); // Направление взгляда камеры (задаётся рендером) — WASD относительно него export const camDir = { yaw: 0, pitch: 0 }; function loop(now) { // ---------- поверхность: верх ТВЁРДОГО блока в колонке ---------- // surfaceGyAt может вернуть тайл, который сам занят блоком (например под // «травой»). Тогда ноги оказываются внутри блока: grounded держится, но // прыжок гасится столкновением на первом же шаге вверх. // Верхний ТВЁРДЫЙ тайл колонки (ось Y вниз: чем больше gy, тем ниже). // Полный скан колонки — единственный надёжный способ: любые эвристики // вида «шагни вверх, пока твёрдые» уходили за границу мира (gy = -1). // Опора = ПОСЛЕДНИЙ твёрдый блок колонки (поверхность земли). // Ось Y вниз, поэтому чем больше gy, тем ниже; поверхность — максимальный // твёрдый gy, перед которым уже вода/воздух. function TDsolidTop(cx, cz) { // ВЕРХНИЙ твёрдый блок колонны = поверхность, на которой можно стоять. // Раньше брался ПОСЛЕДНИЙ (самый нижый, bedrock) — игрока ставило // в фундамент, тело оказывалось внутри блока, камера входила в текстуру. // Обход СВЕРХУ ВНИЗ: первый твёрдый блок, до которого дошли сверху — это и // есть поверхность. Раньше цикл шёл снизу (gy=0) и возвращал ДНО мира // (stone на gy=0), после чего страховка безопасности уносила игрока на // BEDROCK_GY-6 = 133 — то есть глубоко под мир, где только воздух. const TOP = Math.min(state.BEDROCK_GY + 2, 220); for (let gy = TOP; gy >= 0; gy--) { if (isSolid(cx, gy, cz)) return gy; } return -1; } const rawDt = Math.min(0.05, (now - last) / 1000); last = now; const PHYSICS_STEP = 0.016; const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP)); const dt = rawDt / steps; const player = state.player; const inp = state.inp; const mobs = state.mobs; const projectiles = state.projectiles; const boat = state.boat; // на компе джойстик не трогают — полная скорость с клавиатуры const keyboardOnly = Math.abs(inp.kfwd || 0) > 0 || Math.abs(inp.kside || 0) > 0; const jumpPressed = inp.j && !prevJump; prevJump = inp.j; // ---------- день/ночь ---------- if (!state.dayNightCycle) { // время стоит } else if (player.sleeping && isNight()) { state.worldTime += dt * 8 / state.DAY_LEN; player.hp = Math.min(100, player.hp + dt * 20); if (!isNight()) player.sleeping = false; } else { state.worldTime += dt / state.DAY_LEN; } if (state.worldTime >= 1) state.worldTime -= 1; // ---------- генерация мира ---------- // Мир один и фиксированный: строится целиком порциями, ничего не вычищается. ensureGenAroundCamera(); state.worldGenReady = isWorldReady(); state.worldGenProgress = worldGenProgress(); // ---------- кислород/утопление ---------- if (player.headInWater) { player.o2 = Math.max(0, player.o2 - 6 * dt); if (player.o2 === 0) player.hp -= calculateDamage(4 * dt); } else { player.o2 = Math.min(100, player.o2 + 10 * dt); } player.hunger = Math.max(0, player.hunger - dt * 0.2); // ---------- температура по биому (теперь биом 2D) ---------- if (player.temperature === undefined) player.temperature = 15; const BIOME_TEMP = { snow: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 }; const COLD_THRESHOLD = -5; const HEAT_THRESHOLD = 35; const pGX = Math.floor((player.x + player.w / 2) / state.TILE); const pGZ = Math.floor((player.z + player.d / 2) / state.TILE); const pGY = Math.floor((player.y + player.h / 2) / state.TILE); const biome = getCachedBiome(pGX, pGZ); const sGY = surfaceGyAt(pGX, pGZ); const isUnderground = (pGY - sGY) > 2; let targetTemp = BIOME_TEMP[biome] || 15; if (isUnderground) targetTemp = 15; if (isNight() && !isUnderground) targetTemp -= 10; if ((state.weatherType === 'rain' || state.weatherType === 'storm') && !isUnderground) targetTemp -= 5; player.temperature += (targetTemp - player.temperature) * dt * 0.3; // костры/факелы греют, вода в пустыне холодит — ищем в кубе вокруг let nearHeat = false, nearCool = false; for (let dx = -2; dx <= 2 && !nearHeat; dx++) { for (let dy = -2; dy <= 2 && !nearHeat; dy++) { for (let dz = -2; dz <= 2 && !nearHeat; dz++) { const b = getBlock(pGX + dx, pGY + dy, pGZ + dz); if (b && (b.t === 'campfire' || b.t === 'torch' || b.t === 'furnace')) nearHeat = true; } } } if (!nearHeat && biome === 'desert') { for (let dx = -2; dx <= 2 && !nearCool; dx++) { for (let dz = -2; dz <= 2 && !nearCool; dz++) { const b = getBlock(pGX + dx, pGY, pGZ + dz); if (b && b.t === 'water') nearCool = true; } } } if (nearHeat) { player.temperature += (30 - player.temperature) * dt * 2; if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt); } if (nearCool) { player.temperature += (15 - player.temperature) * dt * 1.5; if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) player.hp = Math.min(100, player.hp + 8 * dt); } if (isUnderground && !nearHeat && player.hp < 100 && player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) { player.hp = Math.min(100, player.hp + 2 * dt); } const aboveBlk = getBlock(pGX, pGY + 1, pGZ); if (aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass' && !isUnderground && !nearCool) { player.temperature -= 5 * dt; } if (player.temperature < COLD_THRESHOLD) { player.hp -= 1 * dt; player.hunger -= 0.5 * dt; } if (player.slowTimer > 0) player.slowTimer -= dt; // ---------- кактус: урон в кубе ---------- for (let dx = -1; dx <= 1; dx++) { for (let dy = -1; dy <= 1; dy++) { for (let dz = -1; dz <= 1; dz++) { const cb = getBlock(pGX + dx, pGY + dy, pGZ + dz); if (cb && BLOCKS[cb.t] && BLOCKS[cb.t].hurt && player.invuln <= 0) { player.hp -= 2; player.invuln = 0.5; } } } } // ---------- лёд: скольжение ---------- const underBlk = getBlock(pGX, pGY - 1, pGZ); if (underBlk && BLOCKS[underBlk.t] && BLOCKS[underBlk.t].slip && player.grounded) { player.vx *= 0.98; player.vz *= 0.98; } // ---------- ДВИЖЕНИЕ: WASD относительно камеры ---------- if (player.sleeping) { player.vx = player.vy = player.vz = 0; } else { // Оси ввода -1..1. Джойстик даёт аналог (ax/ay), клавиатура — дискретно (kfwd/kside). let fwd = (inp.ay || 0) + (inp.kfwd || 0); let side = (inp.ax || 0) + (inp.kside || 0); if (inp.l || inp.left) side -= 1; if (inp.r || inp.right) side += 1; if (inp.d || inp.back) fwd += 1; if (inp.up) fwd -= 1; fwd = Math.max(-1, Math.min(1, fwd)); side = Math.max(-1, Math.min(1, side)); // в мировых осях через угол камеры const cy = Math.cos(camDir.yaw), sy = Math.sin(camDir.yaw); let dx = side * cy - fwd * sy; let dz = side * sy + fwd * cy; const len = Math.hypot(dx, dz); if (len > 0.0001) { // аналоговое управление: чем дальше наклонён джойстик, тем быстрее const speed = state.MOVE * (keyboardOnly ? 1 : Math.max(0.45, Math.min(1, len))); dx /= len; dz /= len; player.vx = dx * speed; player.vz = dz * speed; player.facing = Math.atan2(dz, dx); } else { player.vx *= 0.82; player.vz *= 0.82; } } // шаги if (player.grounded && !player.inWater && Math.hypot(player.vx, player.vz) > 50) { if (now / 1000 - player.lastStepTime > 0.35) { playSound('step'); player.lastStepTime = now / 1000; } } // ---------- прыжок / плавание / лодка ---------- if (player.inBoat) { player.vy = 0; player.x = boat.x + 2; player.y = boat.y - 4; player.z = boat.z + 2; player.grounded = true; player.inWater = false; if (jumpPressed) { state.inv.boat = (state.inv.boat || 0) + 1; player.inBoat = false; boat.active = false; player.y += state.TILE; player.vy = -state.JUMP * 0.5; playSound('splash'); } } else if (player.inWater) { player.vx *= 0.90; player.vz *= 0.90; player.vy *= 0.92; if (!jumpPressed && !inp.j) { player.vy += state.GRAV_WATER * dt; } else if (jumpPressed) { player.vy = Math.min(player.vy, -520); } else if (inp.j) { player.vy = Math.min(player.vy, -260); } } else { if (jumpPressed && player.grounded && !player.sleeping) { player.vy = -state.JUMP; player.grounded = false; player.fallStartY = player.y; } } if (!player.inWater && !player.inBoat) { player.vy += state.GRAV * dt; } // ---------- лодка: плывёт, тонет вне воды ---------- if (boat.active) { boat.x += boat.vx * dt; boat.y += boat.vy * dt; boat.z += (boat.vz || 0) * dt; const boatGX = Math.floor(boat.x / state.TILE); const boatGY = Math.floor(boat.y / state.TILE); const boatGZ = Math.floor(boat.z / state.TILE); const below = getBlock(boatGX, boatGY + 1, boatGZ); if (!below || (below.t !== 'water' && below.t !== 'swamp_water')) { state.inv.boat = (state.inv.boat || 0) + 1; player.inBoat = false; boat.active = false; player.y += state.TILE; player.vy = -200; playSound('splash'); } } if (player.inBoat && !boat.active) { state.inv.boat = (state.inv.boat || 0) + 1; player.inBoat = false; player.y += state.TILE; player.vy = -200; playSound('splash'); } // ---------- границы мира по Z ---------- const maxZ = WORLD_DEPTH * state.TILE; if (player.z < -maxZ) { player.z = -maxZ; player.vz = 0; } if (player.z > maxZ) { player.z = maxZ; player.vz = 0; } // ---------- страховка: не проваливаемся в пустоту ---------- // Две проверки: // 1) ушёл глубже бедрока — физически дно мира, телепорт наверх; // 2) стоял и медленно «уезжал» вниз (протухший коллайдер) — тоже наверх. { const feetGY = Math.floor((player.y + player.h) / state.TILE); const headGY = Math.floor(player.y / state.TILE); const cx = Math.floor((player.x + player.w / 2) / state.TILE); const cz = Math.floor((player.z + player.d / 2) / state.TILE); // Спасать только когда под ногами ПУСТОТА (обрыв, провал, bedrock), // а не по близости к «поверхности». Проверка feetGY < sGY - 12 срабатывала // на любом холме и телепортировала игрока наверх — отсюда «улетел». const groundUnder = isSolid(cx, feetGY, cz); const groundNext = isSolid(cx, feetGY - 1, cz); const FLOOR = Math.max(state.BEDROCK_GY - 1, 0); if (feetGY > FLOOR + 2 || headGY > FLOOR || (!groundUnder && !groundNext)) { // ВАЖНО: player.y — ВЕРХ коллайдера, ноги = y + h. // Стоять на поверхности sGY значит: ноги на верхней грани блока, // то есть y + h = sGY * TILE => y = sGY * TILE - h. // Раньше было y = (sGY+1) * TILE — ноги уезжали на h пикселей // в тело блока: игрок тонул, grounded был всегда false. // -1 = под колонкой пусто (обрыв). Тогда оставляем игрока падать: внизу // bedrock, он встанет сам. Раньше тут был FLOOR-6 = 133 — игрока // выносило в пустоту над миром. const safeY = TDsolidTop(cx, cz); // safeY < 0 = под колонкой пусто (обрыв над миром). Тогда игрока не // трогаем: пусть падает, внизу bedrock. if (safeY >= 0) { // Ось Y направлена ВНИЗ. Верхняя грань блока safeY = safeY * TILE, // Ось Y направлена ВНИЗ. Тайл safeY занимает [safeY*T, (safeY+1)*T]. // Верхняя грань = (safeY+1)*T. Ноги (y + h) стоят РОВНО на ней: // y = (safeY+1)*T - h. Раньше было safeY*T - h, и ноги оказывались // внутри тайла safeY: игрок тонул, grounded был false, камера уходила // в блок и кадр показывал пустое небо. player.y = (safeY + 1) * state.TILE - player.h; player.vy = 0; player.grounded = true; player.fallStartY = player.y; } } } // ---------- ФИЗИКА: X → Z → Y ---------- const sub = dt; // ВАЖНО: координаты НЕ двигаем здесь. resolveX/Z/Y сами применяют // смещение через moveAxis (там же проверка столкновения и автошаг). // Двойное применение смещения ломало прыжок: вверх тело смещалось // вручную, а moveAxis проверял ту же позицию и откатывал обратно, // обнуляя vy в тот же кадр. for (let step = 0; step < steps; step++) { resolveX(player, sub); resolveZ(player, sub); resolveY(player, sub); } // лестница: вертикальное перемещение if (onLadder(player)) { if (inp.j) player.vy = -200; else if (inp.s || inp.down) player.vy = 100; else player.vy = 0; } // урон от падения: дистанция от последнего касания земли if (player.grounded) { if (player.fallStartY !== undefined) { const fallDist = player.fallStartY - player.y; if (fallDist > state.TILE * 6) { player.hp -= calculateDamage((fallDist / state.TILE - 6) * 2.5); } } player.fallStartY = player.y; } else if (player.vy > 0 && player.fallStartY === undefined) { player.fallStartY = player.y; } // вода updateWaterFlag3(player, dt); sendPlayerPosition(); updateWeather(dt); updateRain(dt); player.invuln = Math.max(0, player.invuln - dt); // ---------- мобы ---------- state.spawnT += dt; if (state.spawnT > 6) { state.spawnT = 0; spawnMobs3D(); } for (const m of mobs) { mobAI(m, dt); // физика 3D внутри mob-ai3d } // снаряды for (let i = projectiles.length - 1; i >= 0; i--) { const pr = projectiles[i]; pr.x += pr.vx * dt; pr.y += pr.vy * dt; pr.life -= dt; // столкновение с блоком (по Z игрока/снаряда) const gx = Math.floor(pr.x / state.TILE); const gy = Math.floor(pr.y / state.TILE); const gz = Math.floor((pr.z || player.z) / state.TILE); const b = getBlock(gx, gy, gz); if (b && BLOCKS[b.t] && BLOCKS[b.t].solid) { projectiles.splice(i, 1); continue; } if (pr.life <= 0) projectiles.splice(i, 1); } // ---------- TNT ---------- for (const b of state.blocks) { if (b.dead || b.t !== 'tnt' || !b.active) continue; b.fuse -= dt; if (b.fuse <= 0) { explodeTNT3D(b, state); } } // ---------- печь ---------- tickFurnaces(dt); if (state.currentFurnaceKey && Math.random() < 0.1) renderFurnaceUI(); // ---------- смерть/возрождение ---------- if (player.hp <= 0 && state.deathEl && state.deathEl.style.display !== 'flex') { player.hp = 0; state.deathEl.style.display = 'flex'; } else if (player.hp > 0 && state.deathEl && state.deathEl.style.display === 'flex') { state.deathEl.style.display = 'none'; } // ---------- HUD ---------- if (Math.random() < 0.25) { state.hpEl.textContent = Math.max(0, Math.ceil(player.hp)); state.foodEl.textContent = Math.ceil(player.hunger); const o2El = document.getElementById('o2'); if (o2El) o2El.textContent = Math.ceil(player.o2); const tempEl = document.getElementById('temp'); if (tempEl) { const t = Math.round(player.temperature); tempEl.textContent = t + '°C'; if (player.temperature < COLD_THRESHOLD) tempEl.style.color = '#74b9ff'; else if (player.temperature > HEAT_THRESHOLD) tempEl.style.color = '#e17055'; else tempEl.style.color = '#55efc4'; } state.sxEl.textContent = pGX; state.syEl.textContent = pGY; state.todEl.textContent = isNight() ? 'Ночь' : 'День'; state.worldIdEl.textContent = state.worldId; // XP-бар: в 3D считается ЗДЕСЬ. Раньше элементы #xplevel и #xpbar были // в HTML, но не имели обработчика вообще — ни в 2D, ни в 3D, и висели // вечно на «Lv.1 | 0/50». Порог уровня: 50 * level. const xpEl = document.getElementById('xpbar'); const lvlEl = document.getElementById('xplevel'); if (xpEl && lvlEl) { const lvl = player.level || 1; const need = 50 * lvl; const xp = player.xp || 0; // И уровень, и бар пишем КАЖДЫЙ раз: раньше lvlEl.textContent // только читалcя, но никогда не присваивался, и на экране вечно // висело «Lv.1» независимо от реального уровня. lvlEl.textContent = String(lvl); xpEl.textContent = Math.min(xp, need) + '/' + need; if (lvlEl.parentElement) { lvlEl.parentElement.style.color = xp >= need ? '#7ee08a' : ''; } } if (state.isMultiplayer) { const st = document.getElementById('multiplayerStatus'); if (st) st.style.display = 'flex'; state.playerCountEl.textContent = state.otherPlayers.size + 1; } else { const st = document.getElementById('multiplayerStatus'); if (st) st.style.display = 'none'; } } // сон — DOM-оверлей const sleepOv = document.getElementById('sleepOverlay'); if (sleepOv) sleepOv.style.display = player.sleeping ? 'flex' : 'none'; // ---------- РЕНДЕР ---------- renderFrame3D(now); requestAnimationFrame(loop); } // ---------- спавн мобов в 3D ---------- function spawnMobs3D() { const p = state.player; const pcx = p.x / state.TILE; const pcy = p.y / state.TILE; const pcz = (p.z || 0) / state.TILE; const kinds = [Zombie, Pig, Chicken, Creeper, Skeleton, Scorpion, PolarBear, Slime, Eagle]; for (let i = 0; i < 2; i++) { const ang = Math.random() * Math.PI * 2; const dist = 14 + Math.random() * 12; const mx = Math.floor(pcx + Math.cos(ang) * dist); const mz = Math.floor(pcz + Math.sin(ang) * dist); if (Math.abs(mz) > WORLD_DEPTH) continue; const sy = surfaceGyAt(mx, mz) - 1; if (sy < 1) continue; const K = kinds[Math.floor(Math.random() * kinds.length)]; const m = new K(mx * state.TILE, sy * state.TILE, mz * state.TILE); m.z = mz * state.TILE; m.d = m.d || m.w; m.vz = 0; state.mobs.push(m); } }