Files
shacraft-core/client/app.js
T

1152 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}