548 lines
18 KiB
JavaScript
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);
|
|
} |