MVP checks / mvp (push) Waiting to run
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.
1611 lines
59 KiB
JavaScript
1611 lines
59 KiB
JavaScript
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();
|