1152 lines
35 KiB
JavaScript
1152 lines
35 KiB
JavaScript
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);
|
||
}
|