import { Renderer } from "./renderer.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: "lobby", revision: null, blocks: new Map(), materials: new Map(), registry: [], players: new Map(), entities: [], entityDefs: new Map(), position: [0, 2, 8], target: [0, 2, 8], yaw: 0, pitch: -0.14, 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, resync: false, viewCenter: null, 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; 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, lastInput = 0, lastSelection = "", packageCache = null; 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", "diagnostics"].some((id) => !$(id).hidden) || !$("chat-form").hidden; 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(); if (state.connected) send({ type: "input", seq: ++state.seq, yaw: state.yaw, pitch: state.pitch, forward: 0, strafe: 0, jump: false, }); } function closePanels() { for (const id of ["menu", "worlds", "library", "diagnostics"]) $(id).hidden = true; } function panel(id) { const wasOpen = !$(id).hidden; closePanels(); document.exitPointerLock?.(); controlsZero(); $(id).hidden = wasOpen; if (!wasOpen && id === "library") { loadCatalog(true); $("catalog-search").focus(); } if (!wasOpen && id === "worlds") loadWorlds(); if (!wasOpen && id === "diagnostics") fetchMetrics(); } function release() { document.exitPointerLock?.(); controlsZero(); } function acquire() { unlockAudio(); closePanels(); $("chat-form").hidden = true; if (!state.connected) { toast("Соединение с сервером ещё не установлено."); return; } try { const p = canvas.requestPointerLock?.(); p?.catch?.(() => { pointerFallback = true; toast( "Захват мыши недоступен: перетаскивайте для обзора, щёлкните для правки.", ); }); } catch { pointerFallback = true; toast("Для обзора удерживайте кнопку мыши и двигайте курсор."); } canvas.focus(); } function addMaterials(items = []) { let changed = false; 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) changed = true; } return changed; } function snapshot(m) { state.id = m.id ?? state.id; state.world = m.world || state.world; state.registry = m.registry || state.registry; addMaterials(m.materials); state.blocks = new Map(); for (const b of m.blocks || []) if (b.block) state.blocks.set(key(b.pos), b.block); state.revision = m.revision; state.resync = false; state.viewCenter = m.view_center || m.spawn?.map((v) => Math.floor(v / 16) * 16) || null; if (m.spawn) { state.position = [...m.spawn]; state.target = [...m.spawn]; } if (m.players) updatePlayers(m.players, true); if (m.entities) state.entities = m.entities; renderer.replace(state.blocks, state.materials); $("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 (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, { ...p, position: old && !instant ? old.position : [...p.position], target: [...p.position], }); } for (const id of state.players.keys()) if (!present.has(id)) state.players.delete(id); } function inView(pos) { const center = state.viewCenter; return ( !center || pos.every( (v, i) => v >= center[i] - (i === 1 ? 8 : 32) && v <= center[i] + 31, ) ); } function blockUpdate(m) { if (state.revision === null || state.resync) return; if (m.revision <= state.revision) return; if (m.revision !== state.revision + 1) { state.resync = true; send({ type: "resync" }); toast("Сверяем изменения с сервером…"); 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))); 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; renderer.change(changes); 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; 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 "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(); } async function verifyPackages(manifest, generation) { if (!crypto.subtle) throw Error( "Для проверки пакетов нужен безопасный контекст (localhost или HTTPS).", ); state.packFiles = 0; state.packBytes = 0; state.packCached = 0; 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 (!cached && packageCache) try { await packageCache.put(cacheURL, new Response(blob)); } catch { /* Quota failures do not weaken integrity verification. */ } state.packFiles++; state.packBytes += blob.size; state.packCached += cached ? 1 : 0; verifiedFiles.set(file.path, blob); $("package-status").textContent = `${state.packFiles} файлов проверено · ${bytes(state.packBytes)} · из кэша ${state.packCached}`; connection(`Пакеты ${state.packFiles}`); } // Resolve declared references only after every resource passed integrity checks. // The supported client hook is declarative `effect: bounce`, 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 (style.schema !== 1) throw Error(`Неизвестная схема стиля пакета ${pack.id}`); 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(); } } } } if (!state.packFiles) $("package-status").textContent = "Сервер не требует дополнительных файлов."; 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; 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, name: state.name, world: state.world, manifest_hash: manifest.hash, }); }); ws.addEventListener("message", (event) => { if (generation !== wsGeneration) return; try { onMessage(JSON.parse(event.data)); } 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.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.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; const loaded = new Set(items.map((material) => material.id)); renderer.change( [...state.blocks] .filter(([, id]) => loaded.has(id)) .map(([position, block]) => ({ pos: position.split(",").map(Number), block, })), ); 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 || isUIOpen()) return; const hit = state.selection; if (!hit) return; const now = performance.now(); if (now - lastAction < 130) return; lastAction = now; send({ type: "input", seq: ++state.seq, yaw: state.yaw, pitch: state.pitch, forward: (held.has("KeyW") ? 1 : 0) - (held.has("KeyS") ? 1 : 0), strafe: (held.has("KeyD") ? 1 : 0) - (held.has("KeyA") ? 1 : 0), jump: held.has("Space"), }); if (kind === "break") send({ type: "break", pos: hit.pos }); else if (kind === "place") { const mat = state.materials.get(state.hotbar[state.slot]); if (!mat?.id) return; send({ type: "place", pos: hit.pos.map((v, i) => v + hit.normal[i]), block: mat.id, state: mat.state, }); } 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"); $("menu-button").onclick = () => panel("menu"); $("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/.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 (editing) return; if ( [ "Space", "KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab", "F3", ].includes(e.code) ) e.preventDefault(); if (e.repeat) return; if (e.code === "KeyE") { panel("library"); return; } if (e.code === "F3") { panel("diagnostics"); 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 (!isUIOpen()) held.add(e.code); }); window.addEventListener("keyup", (e) => held.delete(e.code)); window.addEventListener("blur", () => { drag = null; controlsZero(); }); document.addEventListener("visibilitychange", () => { if (document.hidden) controlsZero(); }); document.addEventListener("pointerlockchange", () => { const locked = document.pointerLockElement === canvas; document.body.classList.toggle("locked", locked); if (!locked) controlsZero(); }); 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(); return; } if (document.pointerLockElement === canvas) { action(e.button === 2 ? "place" : e.button === 1 ? "pick" : "break"); return; } drag = { 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) { pointerFallback = true; look(dx, dy); } drag.x = e.clientX; drag.y = e.clientY; }); canvas.addEventListener("pointerup", (e) => { if (!drag) return; const d = drag; drag = null; 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"); } catch {} updateDiagnostics(); } function updateDiagnostics() { 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"], ["Кадры / с", String(state.fps)], ["Треугольники", renderer?.triangles.toLocaleString("ru-RU") || "0"], ["Ожидают построения", String(renderer?.dirty.size || 0)], ["Тик сервера", String(state.tick)], ["Подтверждён ввод", String(state.ack)], ["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} файлов`], ]; $("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.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.webgl = renderer?.lost ? "lost" : "ready"; canvas.dataset.connected = String(state.connected); } 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] + 1.95, 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 dt = Math.min(0.05, (time - lastFrame) / 1000); lastFrame = time; state.frames++; const blend = 1 - Math.exp(-dt * 19); for (let i = 0; i < 3; i++) state.position[i] += (state.target[i] - state.position[i]) * blend; for (const p of state.players.values()) for (let i = 0; i < 3; i++) p.position[i] += (p.target[i] - p.position[i]) * blend; const camera = { eye: [state.position[0], state.position[1] + 1.62, 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], ) : ""; } renderer.draw( camera, [...state.players.values()], state.entities, state.entityDefs, ); updateLabels(); if (state.connected && time - lastInput >= 50) { lastInput = time; let forward = isUIOpen() ? 0 : (held.has("KeyW") || held.has("ArrowUp") ? 1 : 0) - (held.has("KeyS") || held.has("ArrowDown") ? 1 : 0), strafe = isUIOpen() ? 0 : (held.has("KeyD") || held.has("ArrowRight") ? 1 : 0) - (held.has("KeyA") || held.has("ArrowLeft") ? 1 : 0); send({ type: "input", seq: ++state.seq, yaw: state.yaw, pitch: state.pitch, forward, strafe, jump: !isUIOpen() && held.has("Space"), }); } if (time - fpsTime > 1000) { state.fps = Math.round((state.frames * 1000) / (time - fpsTime)); state.frames = 0; fpsTime = time; updateDiagnostics(); } requestAnimationFrame(frame); } try { renderer = new Renderer(canvas); renderHotbar(); connect(); requestAnimationFrame(frame); 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); }