grechka-game/srcv3/game/loop3d.js

504 lines
22 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.

// ==================== ЦИКЛ ИГРЫ 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);
}
}