Files
Emil c7e86663d8
MVP checks / mvp (push) Waiting to run
Expand voxel gameplay, lighting, full-height streaming and world imports
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
2026-09-17 02:10:53 +03:00

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