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