grechka-game/src3d/game/loop.js

548 lines
18 KiB
JavaScript

// ==================== ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ ====================
import { state } from '../core/state.js';
import { BLOCKS } from '../data/blocks.js';
import { playSound } from '../audio/sound-engine.js';
import { getBlock } from '../world/world-storage.js';
import { updateWaterPhysics } from '../world/water.js';
import { updateWaterFlag } from '../physics/water-detect.js';
import { resolveY, resolveX } from '../physics/collision.js';
import { calculateDamage } from '../entities/player.js';
import { isNight } from '../entities/mob-ai.js';
import { tickFurnaces } from '../ui/furnace.js';
import { renderFurnaceUI } from '../ui/furnace.js';
import { updateWeather, updateRain, drawWeather, skyColor } from '../world/weather.js';
import { ensureGenAroundCamera, surfaceGyAt, genColumn, getCachedBiome, getBiome } from '../world/generation.js';
import { Zombie, Creeper, Skeleton, Pig, Chicken, Scorpion, PolarBear, Slime, Eagle } from '../entities/mobs.js';
import { mobAI } from '../entities/mob-ai.js';
import { explodeAt } from '../world/tnt.js';
import { sendPlayerPosition } from '../multiplayer/socket-helpers.js';
import { renderMinimap } from '../ui/minimap.js';
import { rebuildHotbar } from '../ui/hotbar.js';
import { mode } from '../game/modes.js';
import { mouse } from '../input/mouse-handler.js';
import { drawFire } from '../render/draw-fire.js';
import { itemTex } from '../render/textures.js';
import { renderFrame3D } from '../render/render3d.js';
// ==================== LOOP ====================
let last = performance.now();
let prevJump = false;
// При возврате на вкладку — сбрасываем last чтобы не было скачка dt
document.addEventListener('visibilitychange', () => {
if (!document.hidden) last = performance.now();
});
export function loop(now) {
const rawDt = Math.min(0.05, (now - last) / 1000);
last = now;
// Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки
const PHYSICS_STEP = 0.016;
const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
const dt = rawDt / steps;
const player = state.player;
const inv = state.inv;
const inp = state.inp;
const clouds = state.clouds;
const mobs = state.mobs;
const projectiles = state.projectiles;
const parts = state.parts;
const boat = state.boat;
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;
// камера следует за игроком по X/Y
state.camX = Math.floor((player.x + player.w / 2) - state.W / 2);
state.camY = Math.floor((player.y + player.h / 2) - state.H / 2);
ensureGenAroundCamera();
// clouds parallax
for (const c of clouds) {
c.x -= c.s * dt;
if (c.x + c.w < state.camX - 400) c.x = state.camX + state.W + Math.random() * 700;
}
// player
updateWaterFlag(player);
// кислород/утопление
if (player.headInWater) {
player.o2 = Math.max(0, player.o2 - 6 * dt);
if (player.o2 === 0) {
const damage = calculateDamage(4 * dt);
player.hp -= damage;
}
} else {
player.o2 = Math.min(100, player.o2 + 10 * dt);
}
// голод убывает
player.hunger = Math.max(0, player.hunger - dt * 0.2);
// ========== TEMPERATURE ==========
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 HEAT_RADIUS = 5;
const pGX = Math.floor(player.x / state.TILE);
const biome = getCachedBiome(pGX);
const pGY = Math.floor(player.y / state.TILE);
const sGY = surfaceGyAt(pGX);
const isUndergroundTemp = (pGY - sGY) > 2;
let targetTemp = BIOME_TEMP[biome] || 15;
if (isUndergroundTemp) targetTemp = 15;
if (isNight() && !isUndergroundTemp) targetTemp -= 10;
if ((state.weatherType === 'rain' || state.weatherType === 'storm') && !isUndergroundTemp) targetTemp -= 5;
player.temperature += (targetTemp - player.temperature) * dt * 0.3;
let nearHeat = false, nearCool = false;
for (let dx = -HEAT_RADIUS; dx <= HEAT_RADIUS; dx++) {
for (let dy = -HEAT_RADIUS; dy <= HEAT_RADIUS; dy++) {
const b = getBlock(pGX + dx, pGY + dy);
if (!b || b.dead) continue;
if (b.t === 'campfire' || b.t === 'torch') nearHeat = true;
if (b.t === 'water' && biome === 'desert') 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 (isUndergroundTemp && !nearHeat) {
if (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);
const inShade = aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass';
if (inShade && !isUndergroundTemp && !nearCool) {
player.temperature -= 5 * dt;
if (player.temperature > HEAT_THRESHOLD - 2 && player.hp < 100) player.hp = Math.min(100, player.hp + 1 * dt);
}
if (player.temperature < COLD_THRESHOLD && !isUndergroundTemp) {
const severity = Math.abs(player.temperature - COLD_THRESHOLD) / 15;
player.hp -= 1 * severity * dt;
if (severity > 0.5) player.vx *= (1 - 0.3 * Math.min(1, severity) * dt);
}
if (player.temperature > HEAT_THRESHOLD && !isUndergroundTemp) {
const severity = (player.temperature - HEAT_THRESHOLD) / 15;
player.hp -= 1 * severity * dt;
if (severity > 0.5) player.hunger -= 0.5 * severity * dt;
}
if (player.slowTimer > 0) player.slowTimer -= dt;
// Кактус — урон при касании
const pCGX = Math.floor((player.x + player.w / 2) / state.TILE);
const pCGY = Math.floor((player.y + player.h / 2) / state.TILE);
for (let cdx = -1; cdx <= 1; cdx++) {
for (let cdy = -1; cdy <= 1; cdy++) {
const cb = getBlock(pCGX + cdx, pCGY + cdy);
if (cb && !cb.dead && BLOCKS[cb.t] && BLOCKS[cb.t].hurt) {
if (player.invuln <= 0) {
player.hp -= 2;
player.invuln = 0.5;
}
}
}
}
// Лёд — скольжение
const iceBlock = getBlock(pCGX, pCGY + 1);
if (iceBlock && BLOCKS[iceBlock.t] && BLOCKS[iceBlock.t].slip && player.grounded) {
player.vx *= 0.98;
}
// Игрок не может двигаться во время сна
if (player.sleeping) {
player.vx = 0;
player.vy = 0;
} else {
const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
if (dir) player.vx = dir * state.MOVE;
else player.vx *= 0.82;
}
// Звук шагов при движении по земле
if (player.grounded && !player.inWater && Math.abs(player.vx) > 50) {
const stepInterval = 0.35;
if (now / 1000 - player.lastStepTime > stepInterval) {
playSound('step');
player.lastStepTime = now / 1000;
}
}
// прыжок/плавание
if (player.inBoat) {
const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
if (dir) boat.vx = dir * state.MOVE;
else boat.vx *= 0.95;
boat.vy = 0;
player.x = boat.x + 2;
player.y = boat.y - 4;
player.vx = boat.vx;
player.vy = boat.vy;
player.grounded = true;
player.inWater = false;
if (jumpPressed) {
inv.boat = (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.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;
const boatGX = Math.floor(boat.x / state.TILE);
const boatGY = Math.floor(boat.y / state.TILE);
const below = getBlock(boatGX, boatGY + 1);
if (!below || below.t !== 'water') {
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
boat.active = false;
player.y += state.TILE;
player.vy = -200;
playSound('splash');
}
}
if (player.inBoat && !boat.active) {
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
player.y += state.TILE;
player.vy = -200;
playSound('splash');
}
// Sub-stepped physics
for (let step = 0; step < steps; step++) {
player.y += player.vy * dt;
resolveY(player);
player.x += player.vx * dt;
resolveX(player);
}
sendPlayerPosition();
updateWaterPhysics(dt);
updateWeather(dt);
updateRain(dt);
player.invuln = Math.max(0, player.invuln - dt);
// Voice position update
state.voicePosT += dt;
if (state.voicePosT > 0.5 && state.voiceSocket && state.voiceSocket.connected) {
state.voicePosT = 0;
state.voiceSocket.emit('voice_pos', { x: player.x, y: player.y });
}
// Furnace tick
tickFurnaces(dt);
if (state.currentFurnaceKey && Math.random() < 0.1) {
renderFurnaceUI();
}
// Projectile tick
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += 400 * dt;
p.life -= dt;
const gx = Math.floor(p.x / state.TILE);
const gy = Math.floor(p.y / state.TILE);
const blk = getBlock(gx, gy);
if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid) {
if (p.owner === 'player' && Math.random() < 0.5) inv.arrow++;
projectiles.splice(i, 1);
continue;
}
if (p.owner === 'mob') {
if (p.x > player.x && p.x < player.x + player.w && p.y > player.y && p.y < player.y + player.h) {
if (player.invuln <= 0) {
player.hp -= calculateDamage(p.dmg);
player.invuln = 0.4;
player.vx += p.vx * 0.3;
player.vy -= 150;
playSound('hit1');
}
projectiles.splice(i, 1);
continue;
}
} else {
let hitMob = false;
if (state.isMultiplayer) {
for (const [id, sm] of state.serverMobs) {
if (sm.dead) continue;
if (p.x > sm.x && p.x < sm.x + sm.w && p.y > sm.y && p.y < sm.y + sm.h) {
state.socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx });
projectiles.splice(i, 1);
hitMob = true;
break;
}
}
}
if (!hitMob) {
for (let j = mobs.length - 1; j >= 0; j--) {
const m = mobs[j];
if (p.x > m.x && p.x < m.x + m.w && p.y > m.y && p.y < m.y + m.h) {
m.hp -= p.dmg;
m.vx += p.vx * 0.2;
m.vy -= 200;
if (m.hp <= 0) {
inv.meat += (m.kind === 'chicken' ? 1 : 2);
if (m.kind === 'skeleton') {
inv.arrow += 2 + Math.floor(Math.random() * 3);
if (Math.random() < 0.15) inv.bow = (inv.bow || 0) + 1;
}
mobs.splice(j, 1);
rebuildHotbar();
}
projectiles.splice(i, 1);
break;
}
}
}
}
if (p.life <= 0) projectiles.splice(i, 1);
}
// TNT tick
for (const key of Array.from(state.activeTNT)) {
const b = state.grid.get(key);
if (!b || b.dead) { state.activeTNT.delete(key); continue; }
b.fuse -= dt;
if (b.fuse <= 0) {
explodeAt(b.gx, b.gy);
}
}
// ====== CROP GROWTH ======
for (const [key, blk] of state.grid) {
if (blk.dead) continue;
const bDef = BLOCKS[blk.t];
if (bDef.growthStage === undefined) continue;
if (bDef.harvestable) continue; // stage 3 = fully grown
if (!blk.growthTimer && blk.growthTimer !== 0) blk.growthTimer = 0;
blk.growthTimer += dt;
// Growth time from block def (default 30s per stage)
const growTime = bDef.growthTime || 30;
if (blk.growthTimer >= growTime) {
blk.growthTimer = 0;
const nextStage = bDef.growsTo;
if (nextStage) {
blk.t = nextStage;
// Update texture reference
if (tex[nextStage]) {
// Block type changed — will render new texture next frame
}
}
}
}
// mobs spawn — only in singleplayer
state.spawnT += dt;
if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) {
state.spawnT = 0;
const spawnLeft = Math.random() < 0.5;
const gx = spawnLeft
? Math.floor((state.camX - 200) / state.TILE)
: Math.floor((state.camX + state.W + 200) / state.TILE);
genColumn(gx);
const sgy = surfaceGyAt(gx);
const wx = gx * state.TILE + 4;
const wy = (sgy - 2) * state.TILE;
const top = getBlock(gx, sgy);
if (top && top.t === 'water') {
// skip
} else {
const night = isNight();
const spawnBiome = getCachedBiome(gx);
if (night) {
const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton' || m.kind === 'scorpion' || m.kind === 'slime').length;
if (hostileCount < 12) {
const rand = Math.random();
if (spawnBiome === 'desert' && rand < 0.6) {
mobs.push(new Scorpion(wx, wy));
} else if (spawnBiome === 'swamp' && rand < 0.5) {
mobs.push(new Slime(wx, wy));
} else if (rand < 0.35) {
mobs.push(new Zombie(wx, wy));
} else if (rand < 0.55) {
mobs.push(new Creeper(wx, wy));
} else {
mobs.push(new Skeleton(wx, wy));
}
}
}
// Daytime biome mobs
const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken' || m.kind === 'polar_bear' || m.kind === 'eagle').length;
if (animalCount < 8) {
if (spawnBiome === 'tundra' && Math.random() < 0.6) {
mobs.push(new PolarBear(wx, wy));
} else if (spawnBiome === 'mountains' && Math.random() < 0.4) {
mobs.push(new Eagle(wx, wy));
} else {
mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
}
}
}
}
// mobs update — только локальные
if (!state.isMultiplayer) {
for (let i = mobs.length - 1; i >= 0; i--) {
const m = mobs[i];
mobAI(m, dt);
if (m.hp <= 0) mobs.splice(i, 1);
}
}
// particles
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
p.t -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += state.GRAV * dt;
if (p.t <= 0) parts.splice(i, 1);
}
// death
if (player.hp <= 0) {
state.deathEl.style.display = 'flex';
} else if (state.deathEl.style.display === 'flex') {
state.deathEl.style.display = 'none';
}
// ==================== RENDER 3D ====================
const TILE = state.TILE;
const night = isNight();
renderFrame3D(now);
// Стрелы скелета (урон — логика из оригинального рендера, без 2D-отрисовки)
for (const m of mobs) {
if (m.kind === 'skeleton' && m.shootCooldown > 0.5) {
const arrowX = m.x + m.w / 2;
const arrowY = m.y + 15;
const targetX = player.x + player.w / 2;
const targetY = player.y + player.h / 2;
const angle = Math.atan2(targetY - arrowY, targetX - arrowX);
const dx = targetX - arrowX;
const dy = targetY - arrowY;
const dist = Math.hypot(dx, dy);
if (dist < 150 && player.invuln <= 0) {
player.hp -= 8;
player.invuln = 0.5;
player.vx += Math.cos(angle) * 300;
player.vy -= 200;
playSound('hit1');
}
}
}
// HUD — те же DOM-обновления
if (Math.random() < 0.25) {
state.hpEl.textContent = Math.max(0, Math.ceil(player.hp));
state.foodEl.textContent = Math.ceil(player.hunger);
document.getElementById('o2').textContent = Math.ceil(player.o2);
// Temperature display
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 = Math.floor(player.x / TILE);
state.syEl.textContent = Math.floor(player.y / TILE);
state.todEl.textContent = night ? 'Ночь' : 'День';
state.worldIdEl.textContent = state.worldId;
if (state.isMultiplayer) {
document.getElementById('multiplayerStatus').style.display = 'flex';
state.playerCountEl.textContent = state.otherPlayers.size + 1;
} else {
document.getElementById('multiplayerStatus').style.display = 'none';
}
}
// Индикатор сна — DOM-оверлей вместо заливки canvas
const sleepOv = document.getElementById('sleepOverlay');
if (sleepOv) sleepOv.style.display = player.sleeping ? 'flex' : 'none';
// Миникарта
if (state.minimapOpen && Math.random() < 0.25) {
renderMinimap();
}
requestAnimationFrame(loop);
}