import { EditDiagnostics } from "./edit-diagnostics.js"; import { DynamicsDiagnostics, drawDynamicsChart } from "./dynamics-diagnostics.js"; import { normalizeViewDistance, DEFAULT_VIEW_DISTANCE, MAX_VIEW_DISTANCE, viewDistanceProfile } from "./view-distance.js"; import { Renderer } from "./renderer.js?v=large-world-1"; import { SectionVoxelMap } from "./section-voxel-map.js"; import { applySectionView, applySectionBatch, sectionInBounds } from "./section-stream.js"; import { SectionBlockMap } from "./section-block-map.js"; import { PointerLockController } from "./pointer-lock.js"; import { loadPhysics, samplePhysicsWorld, PhysicsClock, LocalPrediction, } from "./player-physics.js?v=movement-1"; import { updateRemotePlayer, smoothRemotePlayer, } from "./player-interpolation.js"; import { getViewBounds, mergeChunkUpdate, mergeSnapshot, positionInView, } from "./world-view.js"; import { clamp, key, direction, normalizeBoxes, raycast, unitBox, } from "./math.js"; const $ = (id) => document.getElementById(id), canvas = $("world-canvas"); const state = { ws: null, id: null, world: new URLSearchParams(location.search).get("world") || "lobby", revision: null, blocks: new SectionBlockMap(), materials: new Map(), registry: [], players: new Map(), entities: [], entityDefs: new Map(), position: [0, 2, 8], target: [0, 2, 8], yaw: 0, pitch: -0.14, eyeHeight: 1.62, eyeTarget: 1.62, physicsStatus: "Загрузка", physicsPose: "standing", playerFlying: false, playerSprinting: false, connected: false, manifest: null, retry: 0, seq: 0, tick: 0, ack: 0, ping: 0, frames: 0, fps: 0, hotbar: [], slot: 0, selection: null, packFiles: 0, packBytes: 0, packCached: 0, texturePack: "", textureCount: 0, textureSize: 0, resync: false, viewCenter: null, viewBounds: null, fullSnapshots: 0, chunkUpdates: 0, sectionStream: false, viewGeneration: 0, loadedSections: new Set(), totalSections: 0, sectionBatches: 0, streamApplyMs: 0, networkBytes: 0, waitingTerrain: false, viewDistance: DEFAULT_VIEW_DISTANCE, viewDistanceSupported: false, metrics: null, name: localStorage.getItem("shacraft:name") || `Игрок_${Math.floor(100 + Math.random() * 900)}`, }; let audioContext = null, bounceSound = null, bounceBuffer = null, bounceDecode = null; const packageStyles = new Map(); function unlockAudio() { try { audioContext ??= new AudioContext(); audioContext.resume().catch(() => {}); if (bounceSound && !bounceDecode) bounceDecode = audioContext .decodeAudioData(bounceSound.slice(0)) .then((buffer) => (bounceBuffer = buffer)) .catch(() => {}); } catch {} } function bounceFeedback() { renderer.bounceAt = performance.now(); if (audioContext && bounceBuffer) { const source = audioContext.createBufferSource(), gain = audioContext.createGain(); source.buffer = bounceBuffer; gain.gain.value = 0.16; source.connect(gain).connect(audioContext.destination); source.start(); } } let pointerFallback = false, physics = null, prediction = null, motionSupported = false, flyToggle = false, lastSpace = -Infinity; const physicsClock = new PhysicsClock(); let renderer, wsGeneration = 0, retryTimer, toastTimer, catalogTimer, lastFrame = performance.now(), fpsTime = lastFrame, catalogOffset = 0, catalogTotal = 0, catalogQuery = "", catalogGeneration = 0, catalogBusy = false, held = new Set(), drag = null, lastAction = 0, lastSelection = "", packageCache = null; const frameSamples = []; const dynamics = new DynamicsDiagnostics(); const editDiagnostics = new EditDiagnostics(); let lastDynamicsStatus = "idle"; const pointerLock = new PointerLockController(canvas, { onChange(locked) { document.body.classList.toggle("locked", locked); canvas.dataset.pointerLock = locked ? "locked" : "idle"; if (locked) { if (drag && canvas.hasPointerCapture(drag.pointerId)) canvas.releasePointerCapture(drag.pointerId); drag = null; pointerFallback = false; delete canvas.dataset.pointerLockError; updateMouseHint(); } else controlsZero(); }, onError(error) { canvas.dataset.pointerLock = "error"; canvas.dataset.pointerLockError = `${error.name}: ${error.message}`; canvas.dataset.pointerLockFocused = String(document.hasFocus()); toast("Браузер не смог захватить мышь. Попробуйте ещё раз или включите перетаскивание в меню.", true); }, }); function updateMouseHint() { $("mouse-mode").textContent = `Мышь: ${pointerFallback ? "перетаскивание" : "захват"}`; $("play-hint").textContent = pointerFallback ? "Удерживайте кнопку для обзора · щелчок — убрать / поставить блок" : "Нажмите на мир для захвата мыши · Esc — освободить"; } const niceName = (s) => (s || "") .replace(/^minecraft:/, "") .replace(/\[.*$/, "") .replaceAll("_", " "); const baseName = (s) => (s || "").replace(/\[.*$/, ""); const bytes = (n) => Number.isFinite(n) ? n >= 1048576 ? `${(n / 1048576).toFixed(1)} МиБ` : n >= 1024 ? `${(n / 1024).toFixed(1)} КиБ` : `${n} Б` : "—"; const isUIOpen = () => ["menu", "worlds", "library"].some((id) => !$(id).hidden) || !$("chat-form").hidden; const isUIFocused = () => isUIOpen() || (!$("diagnostics").hidden && $("diagnostics").contains(document.activeElement)); function toast(message, error = false, duration = 4300) { $("toast").textContent = message; $("toast").classList.toggle("error", error); $("toast").hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => ($("toast").hidden = true), duration); } function connection(label, bad = false) { $("connection").textContent = label; $("connection").classList.toggle("bad", bad); } function chat(name, text, system = false) { const row = document.createElement("div"); row.textContent = name ? `${name}: ${text}` : text; if (system) row.className = "system"; $("chat-log").append(row); while ($("chat-log").children.length > 6) $("chat-log").firstChild.remove(); } function send(message) { if (state.ws?.readyState === WebSocket.OPEN) { state.ws.send(JSON.stringify(message)); return true; } return false; } function controlsZero() { held.clear(); flyToggle = false; if (state.connected && motionSupported) { send({ type: "input_reset" }); physicsClock.reset(); } else if (state.connected) send({ type: "input", seq: ++state.seq, yaw: state.yaw, pitch: state.pitch, forward: 0, strafe: 0, jump: false, sprint: false, sneak: false, }); } function movementInput(consumeToggle = false) { const automatic=dynamics.controls(consumeToggle,state.playerFlying,performance.now(),Boolean(prediction?.body?.horizontal_collision)); if(automatic && state.connected && !document.hidden){state.yaw=automatic.yaw;state.pitch=automatic.pitch;return automatic;} const enabled = !isUIFocused() && !document.hidden, sneak = enabled && (held.has("ShiftLeft") || held.has("ShiftRight")), input = { yaw: Math.fround(state.yaw), pitch: Math.fround(state.pitch), forward: enabled ? Number(held.has("KeyW") || held.has("ArrowUp")) - Number(held.has("KeyS") || held.has("ArrowDown")) : 0, strafe: enabled ? Number(held.has("KeyD") || held.has("ArrowRight")) - Number(held.has("KeyA") || held.has("ArrowLeft")) : 0, jump: enabled && held.has("Space"), sprint: enabled && !sneak && (held.has("ControlLeft") || held.has("ControlRight")), sneak, fly_toggle: enabled && flyToggle, }; if (consumeToggle) flyToggle = false; return input; } function disablePrediction(error) { console.error("Shacraft prediction disabled", error); prediction = null; physics = null; state.physicsStatus = "Только сервер"; toast("Локальная физика недоступна. Движение рассчитывает сервер.", true); } function receiveMotion(motion, reset = false) { if (!motion || !prediction) return; try { const previous = prediction.authority; if (prediction.receive(motion, reset)) { if ( !reset && previous && previous.velocity[1] <= 0 && motion.body.velocity[1] > 0.4 ) { const under = previous.position.map((value, axis) => Math.floor(value - (axis === 1 ? 0.1 : 0)), ); if ( state.materials.get(state.blocks.get(key(under)))?.effect === "bounce" ) bounceFeedback(); } motionSupported = true; state.ack = motion.ack; state.tick = motion.tick; state.target = [...prediction.body.position]; state.physicsPose = prediction.body.pose; state.playerFlying = Boolean(prediction.body.flying); state.playerSprinting = Boolean(prediction.body.sprinting); if (reset) { state.position = [...prediction.body.position]; physicsClock.reset(); } } } catch (error) { disablePrediction(error); } } function inputStep() { const input = movementInput(true), seq = state.seq + 1; if (!send({ type: "input", seq, ...input })) return; state.seq = seq; if (motionSupported && prediction) { try { prediction.push(seq, input); } catch (error) { disablePrediction(error); } } } function closePanels() { for (const id of ["menu", "worlds", "library"]) $(id).hidden = true; } function panel(id) { const wasOpen = !$(id).hidden; if (id !== "diagnostics") closePanels(); pointerLock.release(); controlsZero(); $(id).hidden = wasOpen; if (wasOpen) canvas.focus(); if (!wasOpen && id === "library") { loadCatalog(true); $("catalog-search").focus(); } if (!wasOpen && id === "worlds") loadWorlds(); if (!wasOpen && id === "diagnostics") fetchMetrics(); } function release() { pointerLock.release(); controlsZero(); } function acquire() { unlockAudio(); closePanels(); $("chat-form").hidden = true; if (!state.connected) { toast("Соединение с сервером ещё не установлено."); return; } canvas.focus(); if (!pointerFallback) pointerLock.request(); } function addMaterials(items = [], remesh = true) { if (items.length) prediction?.invalidateWorld(); const added = new Set(); for (const item of items) { if (!item || !Number.isInteger(item.id)) continue; const prev = state.materials.get(item.id), mat = { ...prev, ...item }; mat.state = mat.state || state.registry[mat.id] || "unknown"; mat.color = mat.color || [150, 152, 142]; mat.render = normalizeBoxes( mat.render || mat.render_boxes || mat.boxes || (mat.id === 0 ? [] : [unitBox]), ); mat.collision = normalizeBoxes( mat.collision || mat.boxes || (mat.solid === false ? [] : [unitBox]), ); mat.transparent = mat.opacity < 1 || /glass|water|leaves|ice/.test(mat.state); const style = packageStyles.get(mat.state) || packageStyles.get(baseName(mat.state)); if (style?.color) mat.color = style.color; mat.effect = style?.effect; state.materials.set(mat.id, mat); if (!prev) added.add(item.id); } if (added.size && renderer && remesh) renderer.change( [...state.blocks] .filter(([, id]) => added.has(id)) .map(([position, block]) => ({ pos: position.split(",").map(Number), block, })), ); return added.size > 0; } function snapshot(m) { editDiagnostics.reset(); const newWorld = m.type === "welcome" || (m.world && m.world !== state.world) || state.revision === null; if (!newWorld && m.revision < state.revision) return; state.id = m.id ?? state.id; state.world = m.world || state.world; state.registry = m.registry || state.registry; addMaterials(m.materials, !newWorld); state.sectionStream = m.features?.includes("chunk_stream_v2") || false; state.viewDistanceSupported = m.features?.includes("view_distance_v1") || false; acceptViewDistance(m); if (newWorld) state.blocks = state.sectionStream ? new SectionVoxelMap() : new SectionBlockMap(); if (state.sectionStream) { state.viewGeneration = m.generation; state.loadedSections = new Set(); state.blocks.readySections = state.loadedSections; state.totalSections = m.total_sections || 0; } const changes = state.sectionStream && !newWorld ? [] : mergeSnapshot(state.blocks, m.blocks || []); state.revision = m.revision; state.resync = false; state.viewCenter = m.view_center || m.spawn?.map((v) => Math.floor(v / 16) * 16) || null; state.viewBounds = getViewBounds(m); renderer.setLightBounds(state.viewBounds); if (state.sectionStream && !newWorld) { const unload=[...state.blocks.loadedSectionKeys()].filter(id=>!sectionInBounds(id.split(",").map(Number),state.viewBounds)).map(id=>id.split(",").map(Number)); for(const pos of unload)state.blocks.deleteSection(pos.join(",")); renderer.unloadSections(unload); } state.fullSnapshots++; if (newWorld && m.spawn) { state.position = [...m.spawn]; state.target = [...m.spawn]; } if (newWorld) { prediction?.reset(); motionSupported = false; physicsClock.reset(); flyToggle = false; } if (m.players) updatePlayers(m.players, newWorld); prediction?.invalidateWorld(); receiveMotion(m.motion, newWorld); if (m.entities) state.entities = m.entities; if (newWorld) renderer.replace(state.blocks, state.materials); else renderer.change(changes); $("world-button").textContent = `${state.world} ▾`; document.title = `Shacraft · ${state.world}`; if (m.type === "welcome") { state.connected = true; state.retry = 0; connection("В сети"); chat("", `Вы вошли в ${state.world}`, true); } seedHotbar(); loadMissingMaterials(); updateDiagnostics(); } function updatePlayers(players, instant = false) { const present = new Set(); for (const p of players) { present.add(p.id); if (p.id === state.id) { if (prediction && motionSupported && !instant) continue; state.eyeTarget = p.eye_height ?? 1.62; state.physicsPose = p.pose || "standing"; state.playerFlying = Boolean(p.flying); state.playerSprinting = Boolean(p.sprinting); if (p.position) { const under = [ Math.floor(state.target[0]), Math.floor(state.target[1] - 0.1), Math.floor(state.target[2]), ], underMat = state.materials.get(state.blocks.get(key(under))); if ( !instant && p.position[1] - state.target[1] > 0.38 && underMat?.effect === "bounce" ) bounceFeedback(); state.target = p.position; if ( instant || Math.hypot(...p.position.map((v, i) => v - state.position[i])) > 4 ) state.position = [...p.position]; } continue; } const old = state.players.get(p.id); state.players.set(p.id, updateRemotePlayer(old, p, instant)); } for (const id of state.players.keys()) if (!present.has(id)) state.players.delete(id); } function inView(pos) { return positionInView(pos, state.viewBounds); } function requestResync() { if (state.resync) return; state.resync = true; send({ type: "resync" }); toast("Сверяем изменения с сервером…"); } function chunkUpdate(m) { if (state.revision === null || state.resync) return; const result = mergeChunkUpdate(state, m, { includeUnloadedChanges: false }); if (result.status === "resync") { requestResync(); return; } if (result.status !== "applied") return; renderer.setLightBounds(state.viewBounds); addMaterials(m.materials, !renderer.terrain); renderer.change(result.changes, [...m.sections.map(section => section.section), ...m.unload], m.sections); renderer.unloadSections(m.unload); state.chunkUpdates++; prediction?.invalidateWorld(); loadMissingMaterials(); } function sectionView(m) { const result=applySectionView(state,m); if(result==="resync")return requestResync(); if(result==="ignored")return; renderer.setLightBounds(state.viewBounds); renderer.unloadSections(result.unload); acceptViewDistance(m); state.chunkUpdates++; prediction?.invalidateWorld(); } function sectionBatch(m) { const start=performance.now(); const result=applySectionBatch(state,m); if(result.status==="resync")return requestResync(); if(result.status!=="applied")return; addMaterials(m.materials,!renderer.terrain); renderer.change([],result.sections.map(s=>s.section),result.sections); renderer.terrain?.update({columns:result.columns}); state.totalSections=m.total_sections || state.totalSections; state.sectionBatches++; prediction?.invalidateWorld(); state.streamApplyMs=performance.now()-start; dynamics.event("sections",state.streamApplyMs,{sections:result.sections.length,loaded:state.loadedSections.size}); loadMissingMaterials(); } function blockUpdate(m) { if (m.world && m.world !== state.world) return; if (state.revision === null || state.resync) return; if (m.revision <= state.revision) return; if (m.revision !== state.revision + 1) { requestResync(); return; } const changes = (m.changes || []).filter((change) => inView(change.pos)); const needed = new Set(changes.map((change) => change.block)); addMaterials((m.materials || []).filter((mat) => needed.has(mat.id)), !renderer.terrain); for (const change of changes) { if (change.block) state.blocks.set(key(change.pos), change.block); else state.blocks.delete(key(change.pos)); } state.revision = m.revision; prediction?.invalidateWorld(); editDiagnostics.confirmed(changes); renderer.change(changes); if(m.columns?.length)renderer.terrain?.update({columns:m.columns.map(item=>({column:item.column,heights:new Int16Array(item.heights)}))}); loadMissingMaterials(); } function onMessage(m) { switch (m.type) { case "welcome": case "snapshot": snapshot(m); break; case "state": state.tick = m.tick ?? state.tick; state.ack = m.ack ?? state.ack; state.waitingTerrain = Boolean(m.motion?.waiting_terrain); receiveMotion(m.motion); if (m.players) updatePlayers(m.players); if (m.entities) state.entities = m.entities; if (m.match) showMatch(m.match); break; case "blocks": blockUpdate(m); break; case "view": sectionView(m); break; case "sections": sectionBatch(m); break; case "chunks": chunkUpdate(m); break; case "chat": chat(m.name, m.text); break; case "error": toast(m.message || m.error || "Сервер отклонил действие", true); if (/revision|resync/i.test(m.message || "")) send({ type: "resync" }); if (state.resync && /snapshot rate limited/i.test(m.message || "")) { const generation = wsGeneration; setTimeout(() => { if (generation === wsGeneration && state.connected && state.resync) send({ type: "resync" }); }, 600); } break; case "pong": state.ping = Math.max( 0, Math.round( performance.now() - Number(m.client_time ?? m.time ?? m.timestamp), ), ); break; case "entities": state.entities = m.entities || []; break; case "match": showMatch(m.match || m); break; case "notice": toast(m.message); break; } } async function getJSON(url) { const response = await fetch(url, { cache: "no-store" }); if (!response.ok) throw Error(`${url.split("?")[0]}: HTTP ${response.status}`); return response.json(); } function updatePackageStatus() { const progress = state.packFiles ? `${state.packFiles} файлов проверено · ${bytes(state.packBytes)} · из кэша ${state.packCached}` : "Сервер не требует дополнительных файлов."; $("package-status").textContent = state.texturePack ? `${progress} · ${state.texturePack}: ${state.textureCount} текстур ${state.textureSize}×${state.textureSize}` : progress; } async function verifyPackages(manifest, generation) { if (!crypto.subtle) throw Error( "Для проверки пакетов нужен безопасный контекст (localhost или HTTPS).", ); state.packFiles = 0; state.packBytes = 0; state.packCached = 0; state.texturePack = ""; state.textureCount = 0; state.textureSize = 0; renderer.clearTexturePack(); packageStyles.clear(); try { packageCache = await caches.open("shacraft-packages-v1"); } catch { packageCache = null; } let total = 0; for (const pack of manifest.packages || []) { const resources = pack.files || pack.resources || []; const verifiedFiles = new Map(); for (const file of resources) { if (generation !== wsGeneration) return; const hash = (file.sha256 || file.hash || "").toLowerCase(); if (!/^[a-f0-9]{64}$/.test(hash)) throw Error(`Некорректный SHA-256: ${pack.id}/${file.path}`); const url = new URL( file.url || `/packages/${pack.id}/${pack.version}/${file.path}`, location.origin, ); if (url.origin !== location.origin) throw Error("Пакеты должны загружаться с игрового сервера."); const size = file.size ?? file.bytes; if ( size !== undefined && (!Number.isSafeInteger(size) || size < 0 || size > 64 * 1024 * 1024) ) throw Error("Превышен лимит размера ресурса."); const cacheURL = new URL(`/__shacraft_cache__/${hash}`, location.origin) .href; let response = await packageCache?.match(cacheURL); const cached = !!response; if (!response) { response = await fetch(url); if (!response.ok) throw Error( `Не удалось загрузить пакет: ${pack.id}/${file.path} (${response.status})`, ); } const blob = await response.blob(); total += blob.size; if (blob.size > 64 * 1024 * 1024 || total > 128 * 1024 * 1024) throw Error("Превышен лимит памяти для пакетов."); if (size !== undefined && blob.size !== size) throw Error(`Размер ресурса не совпадает: ${pack.id}/${file.path}`); const digest = Array.from( new Uint8Array( await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()), ), ) .map((n) => n.toString(16).padStart(2, "0")) .join(""); if (digest !== hash) { await packageCache?.delete(cacheURL); throw Error(`Хеш ресурса не совпадает: ${pack.id}/${file.path}`); } if (generation !== wsGeneration) return; if (!cached && packageCache) try { await packageCache.put(cacheURL, new Response(blob)); } catch { /* Quota failures do not weaken integrity verification. */ } if (generation !== wsGeneration) return; state.packFiles++; state.packBytes += blob.size; state.packCached += cached ? 1 : 0; verifiedFiles.set(file.path, blob); updatePackageStatus(); connection(`Пакеты ${state.packFiles}`); } // Resolve declared references only after every resource passed integrity checks. // Texture packs and the bounce hook are declarative, with no package JS. for (const file of resources.filter( (f) => f.role === "client-style" || f.path?.endsWith("style.json"), )) { const style = JSON.parse(await verifiedFiles.get(file.path).text()); if (generation !== wsGeneration) return; if (style.schema !== 1) throw Error(`Неизвестная схема стиля пакета ${pack.id}`); if (style.texture_pack) { await renderer.setTexturePack(style.texture_pack, verifiedFiles); if (generation !== wsGeneration) return; state.texturePack = style.texture_pack.name; state.textureCount = style.texture_pack.textures.length; state.textureSize = style.texture_pack.pixel_size; updatePackageStatus(); } if (typeof style.block === "string") packageStyles.set(style.block, style); const texture = verifiedFiles.get(style.texture); if (texture) await renderer.setTexture(texture, style.effect === "bounce"); if (style.effect === "bounce") { const shader = verifiedFiles.get(style.shader); if (shader) renderer.setBounceShader(await shader.text()); const sound = verifiedFiles.get(style.sound); if (sound) { bounceSound = await sound.arrayBuffer(); bounceBuffer = null; bounceDecode = null; if (audioContext) unlockAudio(); } } } } updatePackageStatus(); if (packageCache) { const keys = await packageCache.keys(); if (keys.length > 512) await Promise.all( keys.slice(0, keys.length - 512).map((k) => packageCache.delete(k)), ); } } async function connect() { const generation = ++wsGeneration; clearTimeout(retryTimer); const old = state.ws; state.ws = null; old?.close(); state.connected = false; state.id = null; state.players.clear(); state.seq = 0; prediction?.reset(); motionSupported = false; physicsClock.reset(); connection("Подключение"); controlsZero(); try { const manifest = await getJSON("/api/manifest"); if (manifest.protocol !== 1) throw Error(`Неподдерживаемый протокол ${manifest.protocol}`); state.manifest = manifest; await verifyPackages(manifest, generation); if (generation !== wsGeneration) return; const ws = new WebSocket( `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws`, ); state.ws = ws; ws.addEventListener("open", () => { if (generation !== wsGeneration) return; connection("Вход в мир"); send({ type: "join", protocol: 1, features: [ "chunk_stream_v2", "chunk_stream_v1", "view_buffer_v1", "full_height_v1", ...(physics ? ["movement_prediction_v1"] : []), ], name: state.name, world: state.world, view_distance: normalizeViewDistance($("view-distance").value), manifest_hash: manifest.hash, }); }); ws.addEventListener("message", (event) => { if (generation !== wsGeneration) return; try { const start=performance.now(),message=JSON.parse(event.data); state.networkBytes+=event.data.length; onMessage(message); dynamics.event("network",performance.now()-start,{typeName:message.type,bytes:event.data.length}); } catch (error) { console.error("Shacraft protocol error", error); toast(`Ошибка данных сервера: ${error.message}`, true); } }); ws.addEventListener("close", (event) => { if (generation !== wsGeneration) return; state.connected = false; controlsZero(); scheduleReconnect(event.reason || "Соединение потеряно"); }); ws.addEventListener("error", () => connection("Ошибка соединения", true)); await Promise.allSettled([loadWorlds(), loadEntities()]); } catch (error) { if (generation !== wsGeneration) return; console.error(error); scheduleReconnect(error.message); } } function scheduleReconnect(reason) { state.connected = false; const seconds = Math.min(20, 2 ** state.retry++); connection(`Повтор через ${seconds} с`, true); toast( `${reason}. Повторное подключение через ${seconds} с.`, true, Math.min(seconds * 1000, 6000), ); clearTimeout(retryTimer); retryTimer = setTimeout(connect, seconds * 1000); } async function loadEntities() { try { const result = await getJSON("/api/entities"); for (const e of result.items || result.entities || result) state.entityDefs.set(e.name || e.kind, { ...e, render: normalizeBoxes(e.render), }); } catch (error) { console.warn("Entity catalog", error); } } async function loadWorlds() { try { const data = await getJSON("/api/worlds"), worlds = data.worlds || data; $("world-list").replaceChildren(); for (const world of worlds) { const b = document.createElement("button"); b.className = "world-row" + (world.name === state.world ? " current" : ""); const title = document.createElement("strong"); title.textContent = world.name; const subtitle = document.createElement("small"); subtitle.textContent = world.terrain ? `Процедурный мир · seed ${world.terrain.seed} · Y −64…319` : `Ревизия ${world.revision}${world.template ? ` · экземпляр ${world.template}` : ""}`; b.append(title, subtitle); b.onclick = () => { if (!state.connected) { toast("Дождитесь подключения к серверу."); return; } send({ type: "switch_world", world: world.name }); closePanels(); controlsZero(); }; $("world-list").append(b); } } catch (error) { $("world-list").textContent = error.message; } } let missingPending = false; async function loadMissingMaterials() { if (missingPending) return; missingPending = true; try { while (state.connected) { const missing = [...new Set(state.blocks.materialIds ? state.blocks.materialIds() : state.blocks.values())] .filter((id) => !state.materials.has(id)) .slice(0, 128); if (!missing.length) break; const data = await getJSON( `/api/catalog?ids=${missing.join(",")}&limit=128`, ); const items = data.items || data; if (!addMaterials(items)) break; await new Promise((resolve) => setTimeout(resolve, 0)); } seedHotbar(); } catch (error) { console.warn("Material catalog", error); } finally { missingPending = false; } } async function seedHotbar() { if (state.hotbar.length) return; const states = [ "minecraft:grass_block", "minecraft:stone", "minecraft:oak_planks", "minecraft:bricks", "minecraft:glass", "minecraft:oak_log", "minecraft:oak_leaves", "minecraft:snow_block", "shacraft:trampoline", ]; let found = states.map((s) => [...state.materials.values()].find((m) => baseName(m.state) === s), ); if (found.filter(Boolean).length < 5) { try { const result = await getJSON( "/api/catalog?states=" + encodeURIComponent(states.join(",")), ); addMaterials(result.items || result); found = states.map((s) => [...state.materials.values()].find((m) => baseName(m.state) === s), ); } catch {} } const fallback = [...state.materials.values()].filter((m) => m.id !== 0); state.hotbar = found.map( (m, i) => m?.id ?? fallback[i % Math.max(1, fallback.length)]?.id ?? 0, ); renderHotbar(); } function swatch(mat) { const s = document.createElement("span"); s.className = "swatch"; s.style.setProperty( "--block-color", `rgb(${(mat?.color || [150, 152, 142]).join(",")})`, ); s.setAttribute("aria-hidden", "true"); return s; } function renderHotbar() { $("hotbar").replaceChildren(); for (let i = 0; i < 9; i++) { const mat = state.materials.get(state.hotbar[i]); const b = document.createElement("button"); b.className = `slot${i === state.slot ? " active" : ""}`; b.title = `${i + 1}: ${mat?.state || "Пусто"}`; b.setAttribute("aria-label", b.title); b.setAttribute("aria-pressed", i === state.slot ? "true" : "false"); const n = document.createElement("small"); n.textContent = String(i + 1); b.append(n, swatch(mat)); b.onclick = () => { state.slot = i; renderHotbar(); }; $("hotbar").append(b); } $("selected-name").textContent = niceName(state.materials.get(state.hotbar[state.slot])?.state) || "Выберите блок в библиотеке"; } async function loadCatalog(reset = false) { if (reset) { catalogGeneration++; catalogOffset = 0; catalogQuery = $("catalog-search").value.trim(); $("catalog-list").replaceChildren(); catalogBusy = false; } if (catalogBusy) return; catalogBusy = true; const generation = catalogGeneration; $("catalog-more").disabled = true; try { const data = await getJSON( `/api/catalog?query=${encodeURIComponent(catalogQuery)}&offset=${catalogOffset}&limit=60`, ); if (generation !== catalogGeneration) return; const items = data.items || data; catalogTotal = data.total ?? items.length; addMaterials(items); catalogOffset += items.length; $("catalog-count").textContent = `${catalogTotal.toLocaleString("ru-RU")} состояний · Minecraft Java 26.2`; for (const mat of items) { const button = document.createElement("button"); button.className = "catalog-item"; button.title = mat.state; const label = document.createElement("span"); label.textContent = niceName(mat.state); const properties = document.createElement("small"); properties.textContent = mat.state.match(/\[(.*)\]/)?.[1] || baseName(mat.state).split(":")[0]; label.append(properties); button.append(swatch(mat), label); button.onclick = () => { state.hotbar[state.slot] = mat.id; renderHotbar(); closePanels(); toast(`${niceName(mat.state)} · ячейка ${state.slot + 1}`, false, 1800); }; $("catalog-list").append(button); } if (!catalogOffset) { const empty = document.createElement("p"); empty.className = "catalog-empty"; empty.textContent = "Ничего не найдено. Используйте английское имя блока."; $("catalog-list").append(empty); } $("catalog-more").hidden = catalogOffset >= catalogTotal; } catch (error) { if (generation === catalogGeneration) { toast(error.message, true); $("catalog-count").textContent = "Не удалось загрузить каталог"; } } finally { if (generation === catalogGeneration) { catalogBusy = false; $("catalog-more").disabled = false; } } } function showMatch(m) { if (!m || m.phase === "idle" || (m.phase === "waiting" && !m.remaining)) { $("match").hidden = true; return; } $("match").hidden = false; const phases = { countdown: "До начала", active: "Spleef", running: "Spleef", finished: "Матч завершён", waiting: "Ожидаем игроков", }; const winner = m.winner ? ` · Победитель: ${m.winner}` : ""; $("match").textContent = `${phases[m.phase] || m.phase}${m.remaining !== undefined ? ` · ${Math.ceil(m.remaining)} с` : ""}${winner}`; } function action(kind) { if (!state.connected || isUIFocused()) return; const hit = state.selection; if (!hit) return; const now = performance.now(); if (now - lastAction < 130) return; lastAction = now; if (motionSupported) send({ type: "look", yaw: state.yaw, pitch: state.pitch }); if (kind === "break") { if (send({ type: "break", pos: hit.pos })) editDiagnostics.request(hit.pos, 0, now); } else if (kind === "place") { const mat = state.materials.get(state.hotbar[state.slot]); if (!mat?.id) return; const pos = hit.pos.map((v, i) => v + hit.normal[i]); if (send({ type: "place", pos, block: mat.id, state: mat.state, })) editDiagnostics.request(pos, mat.id, now); } else if (kind === "pick") { state.hotbar[state.slot] = hit.block; renderHotbar(); } } function openChat() { release(); closePanels(); $("chat-form").hidden = false; $("chat-input").focus(); } $("library-button").onclick = () => panel("library"); $("diagnostics-button").onclick = () => panel("diagnostics"); $("diagnostics").addEventListener("pointerdown", () => { pointerLock.release(); controlsZero(); }); $("menu-button").onclick = () => panel("menu"); try {$("view-distance").value=String(normalizeViewDistance(localStorage.getItem("shacraft:view-distance")));} catch {} function updateViewDistanceUI() { const selected=normalizeViewDistance($("view-distance").value); $("view-distance-apply").disabled=!state.connected||!state.viewDistanceSupported||selected===state.viewDistance; $("view-distance-status").textContent = state.viewDistanceSupported ? `Сейчас: ${state.viewDistance} ${state.viewDistance<5?"чанка":"чанков"} · загружено ${state.loadedSections.size} / ${state.totalSections} секций` : "Настройка станет доступна после подключения к обновлённому серверу."; $("view-distance-info").textContent = `${viewDistanceProfile(selected).radius} блоков вокруг игрока. ${selected>=6?"Высокая нагрузка на память и видеокарту; подгрузка займёт больше времени.":"Большая дальность увеличивает расход памяти. По умолчанию — 3 чанка."}`; } function acceptViewDistance(message) { if(Number.isInteger(message.view_distance)&&message.view_distance>=2&&message.view_distance<=MAX_VIEW_DISTANCE) { state.viewDistance=message.view_distance; if(renderer)renderer.viewDistance=state.viewDistance; } updateViewDistanceUI(); } $("view-distance").onchange=updateViewDistanceUI; $("view-distance-apply").onclick=()=>{ const chunks=normalizeViewDistance($("view-distance").value); if(!state.connected||!state.viewDistanceSupported)return; send({type:"view_distance",chunks}); try {localStorage.setItem("shacraft:view-distance",String(chunks));} catch {} $("view-distance-status").textContent="Меняем дальность и подгружаем чанки…"; }; updateViewDistanceUI(); $("mouse-mode").onclick = () => { pointerFallback = !pointerFallback; pointerLock.release(); updateMouseHint(); }; updateMouseHint(); function setTimeOfDay(timeOfDay) { const night = timeOfDay !== "day"; renderer.daylight = night ? 0 : 1; renderer.lightingName = night ? "Moonlight" : "Daylight"; $("time-button").textContent = `Освещение: ${night ? "ночь" : "день"}`; } $("time-button").onclick = () => { if (!renderer) return; const timeOfDay = renderer.daylight === 0 ? "day" : "night"; setTimeOfDay(timeOfDay); localStorage.setItem("shacraft:time-of-day", timeOfDay); }; $("world-button").onclick = () => panel("worlds"); for (const b of document.querySelectorAll("[data-close]")) b.onclick = () => ($(b.dataset.close).hidden = true); $("resume-button").onclick = acquire; $("player-name").value = state.name; $("rename-button").onclick = () => { const name = $("player-name").value.trim(); if (!name) return; state.name = name; localStorage.setItem("shacraft:name", name); closePanels(); connect(); }; $("reconnect-button").onclick = () => { closePanels(); connect(); }; $("resync-button").onclick = () => { if (send({ type: "resync" })) toast("Запрошен актуальный снимок мира."); }; $("respawn-button").onclick = () => { send({ type: "respawn" }); closePanels(); }; $("spleef-button").onclick = () => { send({ type: "start_match" }); closePanels(); }; $("catalog-search").oninput = () => { clearTimeout(catalogTimer); catalogTimer = setTimeout(() => loadCatalog(true), 220); }; $("catalog-more").onclick = () => loadCatalog(); $("chat-form").onsubmit = (e) => { e.preventDefault(); const text = $("chat-input").value.trim(); if (text && state.connected) send({ type: "chat", text }); $("chat-input").value = ""; $("chat-form").hidden = true; canvas.focus(); }; window.addEventListener("keydown", (e) => { const editing = /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName); if (e.code === "Escape") { if (!$("chat-form").hidden) { $("chat-form").hidden = true; $("chat-input").blur(); return; } if (isUIOpen()) closePanels(); else if (document.pointerLockElement !== canvas) panel("menu"); controlsZero(); return; } if (e.code === "F3") { e.preventDefault(); if (!e.repeat) panel("diagnostics"); return; } if (editing || (!$("diagnostics").hidden && $("diagnostics").contains(document.activeElement))) return; if ( [ "Space", "KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab", "F3", "ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", ].includes(e.code) ) e.preventDefault(); if (e.repeat) return; if (e.code === "KeyE") { panel("library"); return; } if (e.code === "KeyT" || e.code === "Enter") { openChat(); return; } if (/^Digit[1-9]$/.test(e.code)) { state.slot = Number(e.code.slice(-1)) - 1; renderHotbar(); return; } if (!isUIFocused()) { if (e.code === "Space") { const now = performance.now(); if (now - lastSpace < 300) { flyToggle = true; lastSpace = -Infinity; } else lastSpace = now; } if (e.code === "KeyF") flyToggle = true; held.add(e.code); } }); window.addEventListener("keyup", (e) => held.delete(e.code)); window.addEventListener("blur", () => { drag = null; controlsZero(); }); document.addEventListener("visibilitychange", () => { if (document.hidden) {dynamics.stop(performance.now(),'tab-hidden');controlsZero();} physicsClock.reset(); lastFrame = performance.now(); }); function look(dx, dy) { const sensitivity = Number($("sensitivity").value); state.yaw += clamp(dx, -160, 160) * sensitivity; state.pitch = clamp( state.pitch - clamp(dy, -160, 160) * sensitivity, -1.48, 1.48, ); } document.addEventListener("mousemove", (e) => { if (document.pointerLockElement === canvas) look(e.movementX, e.movementY); }); canvas.addEventListener("contextmenu", (e) => e.preventDefault()); canvas.addEventListener("pointerdown", (e) => { unlockAudio(); if (isUIOpen()) { closePanels(); canvas.focus(); return; } canvas.focus(); if (document.pointerLockElement === canvas) { action(e.button === 2 ? "place" : e.button === 1 ? "pick" : "break"); return; } drag = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, startX: e.clientX, startY: e.clientY, button: e.button, moved: false, }; canvas.setPointerCapture(e.pointerId); }); canvas.addEventListener("pointermove", (e) => { if (!drag || document.pointerLockElement === canvas) return; const dx = e.clientX - drag.x, dy = e.clientY - drag.y; if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) > 3) drag.moved = true; if (drag.moved) { look(dx, dy); } drag.x = e.clientX; drag.y = e.clientY; }); canvas.addEventListener("pointerup", (e) => { if (!drag) return; const d = drag; drag = null; if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); if (!d.moved) { if (d.button === 2) action("place"); else if (d.button === 1) action("pick"); else if (pointerFallback) action("break"); else acquire(); } }); canvas.addEventListener("pointercancel", () => (drag = null)); canvas.addEventListener( "wheel", (e) => { e.preventDefault(); state.slot = (state.slot + (e.deltaY > 0 ? 1 : 8)) % 9; renderHotbar(); }, { passive: false }, ); async function fetchMetrics() { try { state.metrics = await getJSON("/api/metrics"); dynamics.serverSample({tickMs:state.metrics.tick_ms,stream:state.metrics.stream,terrain:state.metrics.terrain,rss:state.metrics.rss_bytes}); } catch {} updateDiagnostics(); } function updateDiagnostics() { updateViewDistanceUI(); const m = state.metrics || {}, storage = m.storage || m.store || m, mem = m.memory || {}, rss = m.rss_bytes ?? m.process_rss_bytes ?? mem.rss_bytes; const rows = [ ["Соединение", state.connected ? "В сети" : "Нет соединения"], ["Мир", state.world], ["Ревизия", state.revision ?? "—"], ["Видимые блоки", state.blocks.size.toLocaleString("ru-RU")], ["Сущности", String(state.entities.length)], ["Игроки", String(state.players.size + (state.id ? 1 : 0))], ["WebGL", renderer?.lost ? "Контекст потерян" : "WebGL2"], ["Захват мыши", pointerFallback ? "Перетаскивание" : ({ idle: "Нажмите на мир", requesting: "Запрошен", locked: "Активен", error: "Ошибка браузера", unsupported: "Не поддерживается" })[pointerLock.status]], ["Ошибка мыши", canvas.dataset.pointerLockError || "—"], ["Освещение", renderer?.lightingName || "—"], ["Свет блоков / неба", `${renderer?.canvas.dataset.blockLight || 0} / ${renderer?.canvas.dataset.skyLight || 15}`], ["Источники света", String(renderer?.blockLightField?.sourceCount || 0)], ["Расчёт света", renderer?.blockLighting.status === "ready" ? "Готов" : "Обновляется"], ["Тени", renderer?.shadowsEnabled && renderer?.shadowReady ? `Мягкие · ${renderer.shadowSize}²` : "Недоступны"], ["Кадры / с", String(state.fps)], ["Кадр p95 / максимум", `${state.frameP95 || 0} / ${state.frameMax || 0} мс`], ["Треугольники", renderer?.triangles.toLocaleString("ru-RU") || "0"], ["Ожидают построения", String(renderer?.dirty.size || 0)], ["Геометрия", renderer?.terrain?.meshWorker ? "Worker · локальный свет" : renderer?.terrain ? "Worker" : "Основной поток"], ["Ближайшие секции", `${renderer?.canvas.dataset.nearMeshes || 0} построено / ${renderer?.canvas.dataset.nearLoaded || 0} загружено`], ["Первые чанки", renderer?.canvas.dataset.firstTerrainMs ? `${renderer.canvas.dataset.firstTerrainMs} мс` : "Загрузка…"], ["Свет секции", `${renderer?.canvas.dataset.localLightMs || 0} мс · ${renderer?.canvas.dataset.localLightCells || 0} ячеек`], ["Подготовка данных", `${renderer?.canvas.dataset.terrainPrepareMs || 0} мс`], ["Построение секции", `${renderer?.canvas.dataset.meshBuildMs || 0} мс`], ["Правка: подготовка", `${renderer?.canvas.dataset.editPrepareMs || 0} мс`], ["Правка: ответ сервера", editDiagnostics.last ? `${editDiagnostics.last.acknowledgementMs.toFixed(1)} мс` : "—"], ["Правка: обновление геометрии", editDiagnostics.last ? `${editDiagnostics.last.geometryMs.toFixed(1)} мс` : "—"], ["Правка: всего", editDiagnostics.last ? `${editDiagnostics.last.totalMs.toFixed(1)} мс` : "—"], ["Загрузка геометрии", `${renderer?.canvas.dataset.meshUploadMs || 0} мс`], ["Меши секций", String(renderer?.sections.size || 0)], ["Перестроения мешей", String(renderer?.meshRebuilds || 0)], ["Сбросы геометрии", String(renderer?.meshResets || 0)], ["Полные снимки", String(state.fullSnapshots)], ["Обновления чанков", String(state.chunkUpdates)], ["Секции загружены", `${state.loadedSections.size} / ${state.totalSections}`], ["Пакеты секций", String(state.sectionBatches)], ["Применение чанков", `${state.streamApplyMs.toFixed(2)} мс`], ["Массивы блоков", bytes(state.blocks.byteLength)], ["Генерация сервера", `${m.terrain?.pending || 0} в очереди · ${(m.terrain?.last_generate_ms || 0).toFixed(2)} мс`], ["Ожидание местности", state.waitingTerrain ? "Да" : "Нет"], ["Тик сервера", String(state.tick)], ["Подтверждён ввод", String(state.ack)], ["Физика", state.physicsStatus], ["Положение тела", state.physicsPose], ["Полёт", state.playerFlying ? "Да" : "Нет"], ["Бег", state.playerSprinting ? "Да" : "Нет"], ["Ввод в пути", String(prediction?.pending.length || 0)], ["Коррекция позиции", `${(prediction?.correction || 0).toFixed(3)} м`], ["Ping", state.ping ? `${state.ping} мс` : "—"], ["Координаты", state.position.map((n) => n.toFixed(1)).join(", ")], ["RAM процесса", bytes(rss)], [ "Данные кэша", bytes(storage.cache_payload_bytes ?? storage.cache?.payload_bytes), ], ["Пакеты", `${state.packFiles} файлов`], [ "Текстурпак", state.texturePack ? `${state.texturePack} · ${state.textureCount} текстур ${state.textureSize}×${state.textureSize}` : "По умолчанию", ], ]; $("debug-values").replaceChildren(); for (const [label, value] of rows) { const dt = document.createElement("dt"); dt.textContent = label; const dd = document.createElement("dd"); dd.textContent = value; dd.dataset.metric = label; $("debug-values").append(dt, dd); } canvas.dataset.editTimings = JSON.stringify(editDiagnostics.last); canvas.dataset.selection = state.selection ? JSON.stringify({pos:state.selection.pos,normal:state.selection.normal,block:state.selection.block}) : ""; canvas.dataset.world = state.world; canvas.dataset.revision = String(state.revision ?? ""); canvas.dataset.blocks = String(state.blocks.size); canvas.dataset.entities = String(state.entities.length); canvas.dataset.viewDistance = String(state.viewDistance); canvas.dataset.viewMin = state.viewBounds?.min.join(",") || ""; canvas.dataset.viewMax = state.viewBounds?.max.join(",") || ""; canvas.dataset.fullHeight = String(state.viewBounds?.fullHeight === true); canvas.dataset.webgl = renderer?.lost ? "lost" : "ready"; canvas.dataset.connected = String(state.connected); canvas.dataset.texturePack = state.texturePack; canvas.dataset.textureCount = String(state.textureCount); canvas.dataset.textureSize = String(state.textureSize); canvas.dataset.fullSnapshots = String(state.fullSnapshots); canvas.dataset.chunkUpdates = String(state.chunkUpdates); canvas.dataset.loadedSections = String(state.loadedSections.size); canvas.dataset.totalSections = String(state.totalSections); canvas.dataset.sectionBatches = String(state.sectionBatches); canvas.dataset.streamApplyMs = String(state.streamApplyMs); canvas.dataset.voxelBytes = String(state.blocks.byteLength || 0); canvas.dataset.waitingTerrain = String(state.waitingTerrain); canvas.dataset.meshResets = String(renderer?.meshResets || 0); canvas.dataset.meshRebuilds = String(renderer?.meshRebuilds || 0); canvas.dataset.sectionMeshes = String(renderer?.sections.size || 0); canvas.dataset.viewCenter = state.viewCenter?.join(",") || ""; canvas.dataset.physics = motionSupported && prediction ? prediction.suspended ? "waiting-world" : "predicted" : "server"; canvas.dataset.predictionPending = String(prediction?.pending.length || 0); canvas.dataset.predictionCorrection = String(prediction?.correction || 0); canvas.dataset.playerPosition = state.position.join(","); canvas.dataset.playerPose = state.physicsPose; canvas.dataset.playerFlying = String(state.playerFlying); canvas.dataset.playerSprinting = String(state.playerSprinting); } const playerLabels = new Map(); function updateLabels() { for (const [id, label] of playerLabels) if (!state.players.has(id)) { label.remove(); playerLabels.delete(id); } for (const [id, p] of state.players) { let label = playerLabels.get(id); if (!label) { label = document.createElement("div"); label.className = "player-label"; $("labels").append(label); playerLabels.set(id, label); } label.textContent = p.name; const at = renderer.project([ p.position[0], p.position[1] + (p.height ?? 1.8) + 0.15, p.position[2], ]); label.hidden = !at || at[0] < 0 || at[0] > canvas.clientWidth || at[1] < 0 || at[1] > canvas.clientHeight; if (at) { label.style.left = `${at[0]}px`; label.style.top = `${at[1]}px`; } } } function frame(time) { const frameStart=performance.now(); const elapsed = Math.max(0, time - lastFrame), dt = Math.min(0.05, elapsed / 1000); lastFrame = time; frameSamples.push(elapsed); if (frameSamples.length > 240) frameSamples.shift(); state.frames++; if (state.connected && !document.hidden) physicsClock.advance(elapsed, inputStep); else physicsClock.reset(); const blend = 1 - Math.exp(-dt * 19); let local = null; if (motionSupported && prediction) { try { local = prediction.sample(physicsClock.alpha, dt, movementInput()); } catch (error) { disablePrediction(error); } } if (local) { state.position = local.position; state.target = [...local.body.position]; state.physicsPose = local.body.pose; state.playerFlying = Boolean(local.body.flying); state.playerSprinting = Boolean(local.body.sprinting); state.eyeHeight += (local.eyeHeight - state.eyeHeight) * blend; state.physicsStatus = prediction.suspended ? "Ожидаем чанки" : "Rust / WebAssembly · 20 Гц"; } else { for (let i = 0; i < 3; i++) state.position[i] += (state.target[i] - state.position[i]) * blend; state.eyeHeight += (state.eyeTarget - state.eyeHeight) * blend; if (state.connected) state.physicsStatus = "Только сервер"; } for (const p of state.players.values()) smoothRemotePlayer(p, blend); const camera = { eye: [ state.position[0], state.position[1] + state.eyeHeight, state.position[2], ], yaw: state.yaw, pitch: state.pitch, }; state.selection = raycast( camera.eye, direction(state.yaw, state.pitch), state.blocks, state.materials, 6, ); const selectionKey = state.selection ? `${key(state.selection.pos)}:${state.selection.block}` : ""; if (selectionKey !== lastSelection) { lastSelection = selectionKey; renderer.updateSelection(state.selection); $("target-label").textContent = state.selection ? niceName( state.materials.get(state.selection.block)?.state || state.registry[state.selection.block], ) : ""; } const drawStart=performance.now(); renderer.draw( camera, [...state.players.values()], state.entities, state.entityDefs, ); const drawEnd=performance.now(); updateLabels(); const sample={frameMs:elapsed,physicsMs:drawStart-frameStart,drawMs:drawEnd-drawStart, position:[...state.position],yaw:state.yaw,correction:prediction?.correction||0, waiting:state.waitingTerrain || Boolean(prediction?.suspended),dirty:renderer.dirty.size, viewChanges:state.chunkUpdates,batches:state.sectionBatches,meshResets:renderer.meshResets, loadedSections:state.loadedSections.size,totalSections:state.totalSections,voxelBytes:state.blocks.byteLength||0}; dynamics.ready(sample,state.connected&&!sample.waiting&&(!state.sectionStream||sample.loadedSections===sample.totalSections)&&!sample.dirty&&!renderer.meshUpload&&renderer.blockLighting.status==="ready"); dynamics.frame(sample); if(dynamics.status!==lastDynamicsStatus){ lastDynamicsStatus=dynamics.status;canvas.dataset.dynamicsStatus=dynamics.status; $("dynamics-status").textContent=({idle:"Готов к записи",warming:"Ожидаем готовность чанков и геометрии…",recording:"Запись идёт. Стоп — кнопка в диагностике или Esc для автомаршрута.",done:"Запись завершена",error:"Не удалось дождаться готовности местности"})[dynamics.status]; if(dynamics.result){$("dynamics-result").hidden=false;$("dynamics-result").textContent=JSON.stringify(dynamics.result,null,2);canvas.dataset.dynamicsResult=JSON.stringify(dynamics.result);$("dynamics-export").disabled=false;} if(dynamics.status==="done") {controlsZero();toast("Диагностика завершена. Результаты доступны в F3.");} } if (time - fpsTime > 1000) { const sorted = [...frameSamples].sort((a, b) => a - b); state.frameP95 = Math.round((sorted[Math.floor((sorted.length - 1) * 0.95)] || 0) * 10) / 10; state.frameMax = Math.round((sorted.at(-1) || 0) * 10) / 10; state.fps = Math.round((state.frames * 1000) / (time - fpsTime)); state.frames = 0; fpsTime = time; updateDiagnostics(); if(!$("diagnostics").hidden)drawDynamicsChart($("dynamics-chart"),dynamics.recent); if(dynamics.status==="recording")fetchMetrics(); } requestAnimationFrame(frame); } $("dynamics-start").onclick=()=>{ controlsZero(); dynamics.arm($("dynamics-scenario").value,30); $("dynamics-result").hidden=true;$("dynamics-export").disabled=true; closePanels();canvas.focus(); }; $("dynamics-stop").onclick=()=>{dynamics.stop();controlsZero();}; $("dynamics-export").onclick=()=>{ const blob=new Blob([JSON.stringify(dynamics.trace(),null,2)],{type:"application/json"}); const url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download=`shacraft-dynamics-${Date.now()}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000); }; try {new PerformanceObserver(list=>{for(const e of list.getEntries())dynamics.event("longtask",e.duration);}).observe({type:"longtask",buffered:false});}catch{} window.addEventListener("keydown",e=>{if(e.code==="Escape"&&dynamics.scenario!=="record"){dynamics.stop();controlsZero();}}); async function start() { try { renderer = new Renderer(canvas); renderer.onMeshPublished = (id) => { const timing = editDiagnostics.published(id); if (timing) dynamics.event("block-edit", timing.totalMs, timing); }; setTimeOfDay(localStorage.getItem("shacraft:time-of-day") || "night"); renderHotbar(); requestAnimationFrame(frame); try { physics = await loadPhysics(); prediction = new LocalPrediction(physics, (body) => samplePhysicsWorld( body, state.blocks, state.materials, state.viewBounds, ), ); state.physicsStatus = "Готова"; } catch (error) { disablePrediction(error); } connect(); setInterval(() => { if (state.connected) send({ type: "ping", client_time: performance.now() }); if (!$("diagnostics").hidden) fetchMetrics(); }, 3000); } catch (error) { console.error(error); $("fatal").textContent = error.message; $("fatal").hidden = false; connection("Ошибка графики", true); } } start();