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