From c7e86663d8ae03cbd36e1d462a47b74d395139bf Mon Sep 17 00:00:00 2001
From: Emil
Date: Thu, 17 Sep 2026 02:10:53 +0300
Subject: [PATCH] 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.
---
.github/workflows/ci.yml | 1 +
Cargo.lock | 9 +
Cargo.toml | 2 +-
README.md | 21 +-
client/ambient-occlusion.js | 120 +
client/app.js | 731 +-
client/block-light-controller.js | 104 +
client/block-light-worker.js | 15 +
client/block-light.js | 257 +
client/block-textures.js | 101 +
client/dynamics-diagnostics.js | 60 +
client/edit-diagnostics.js | 28 +
client/edit-mesh-controller.js | 43 +
client/edit-mesh-worker.js | 10 +
client/edit-mesh.js | 54 +
client/index.html | 43 +-
client/light-changes.js | 28 +
client/light-properties.js | 78 +
client/light-properties.json | 1 +
client/lighting-shaders.js | 321 +
client/local-light-bounds.js | 10 +
client/math.js | 1 +
client/mesh-geometry.js | 189 +
client/physics.wasm | Bin 0 -> 247211 bytes
client/player-interpolation.js | 21 +
client/player-physics.js | 256 +
client/pointer-lock.js | 115 +
client/renderer.js | 802 +-
client/section-block-map.js | 68 +
client/section-stream.js | 64 +
client/section-voxel-map.js | 75 +
client/shadow-frame.js | 25 +
client/style.css | 13 +-
client/terrain-controller.js | 231 +
client/terrain-mesh-worker.js | 13 +
client/terrain-state.js | 192 +
client/terrain-worker.js | 27 +
client/tests/actor-rendering.test.js | 91 +
client/tests/ambient-occlusion.test.js | 144 +
client/tests/block-light-controller.test.js | 227 +
client/tests/block-light.test.js | 202 +
client/tests/block-textures.test.js | 106 +
client/tests/dynamics-diagnostics.test.js | 17 +
client/tests/edit-diagnostics.test.js | 20 +
client/tests/edit-mesh.test.js | 75 +
client/tests/fixtures/lighting-java26.2.json | 984 +++
client/tests/large-world.test.js | 33 +
client/tests/light-changes.test.js | 69 +
client/tests/light-properties.test.js | 72 +
client/tests/local-terrain-meshing.test.js | 117 +
client/tests/math.test.js | 5 +
client/tests/mesh-geometry.test.js | 132 +
client/tests/physics-wasm.test.js | 133 +
client/tests/player-avatar.test.js | 39 +
client/tests/player-interpolation.test.js | 140 +
client/tests/player-physics.test.js | 208 +
client/tests/pointer-lock.test.js | 169 +
client/tests/renderer-smoke.html | 478 +-
client/tests/renderer-streaming.test.js | 47 +
client/tests/section-block-map.test.js | 98 +
client/tests/section-stream.test.js | 70 +
client/tests/shadow-frame.test.js | 21 +
client/tests/terrain-controller.test.js | 335 +
client/tests/terrain-state.test.js | 253 +
client/tests/terrain-streaming-smoke.html | 148 +
client/tests/terrain-upload.test.js | 236 +
client/tests/texture-pack.test.js | 70 +
client/tests/view-distance.test.js | 86 +
client/tests/world-view.test.js | 358 +
client/texture-pack.js | 70 +
client/view-distance.js | 30 +
client/world-view.js | 206 +
crates/shacraft-compat/src/anvil.rs | 28 +
crates/shacraft-compat/src/lib.rs | 2 +-
crates/shacraft-compat/src/main.rs | 16 +-
crates/shacraft-compat/tests/roundtrip.rs | 52 +-
crates/shacraft-core/src/database.rs | 10 +
crates/shacraft-core/src/store.rs | 88 +-
crates/shacraft-core/tests/generated.rs | 78 +
crates/shacraft-physics/Cargo.toml | 12 +
crates/shacraft-physics/src/lib.rs | 1222 +++
.../tests/fixtures/java26.2.json | 7814 +++++++++++++++++
crates/shacraft-physics/tests/locomotion.rs | 624 ++
crates/shacraft-server/Cargo.toml | 1 +
crates/shacraft-server/src/control.rs | 67 +-
crates/shacraft-server/src/game.rs | 663 +-
crates/shacraft-server/src/main.rs | 15 +-
crates/shacraft-server/src/terrain.rs | 775 ++
crates/shacraft-server/src/tests.rs | 919 +-
crates/shacraft-server/src/world_streaming.rs | 516 ++
docs/CLIENT.md | 50 +-
docs/LIGHTING.md | 129 +
docs/PACKAGES.md | 33 +
docs/PHYSICS.md | 187 +
docs/PHYSICS_PLAN.md | 22 +
docs/SERVER.md | 24 +-
docs/STATUS.md | 10 +-
docs/WORLD_STREAMING.md | 278 +
docs/WORLD_STREAMING_PLAN.md | 28 +
docs/images/minigames-lobby-night.png | Bin 0 -> 1503354 bytes
docs/interop.md | 5 +
scripts/build_physics.sh | 7 +
scripts/check_chunk_streaming.mjs | 293 +
scripts/check_player_physics.mjs | 221 +
scripts/lighting_reference.java | 129 +
scripts/measure_lighting.py | 88 +
scripts/measure_physics.py | 70 +
scripts/physics_reference.java | 332 +
scripts/prepare_texture_pack.py | 160 +
scripts/prepare_vanilla_texture_pack.py | 262 +
scripts/verify.sh | 7 +-
111 files changed, 24257 insertions(+), 598 deletions(-)
create mode 100644 client/ambient-occlusion.js
create mode 100644 client/block-light-controller.js
create mode 100644 client/block-light-worker.js
create mode 100644 client/block-light.js
create mode 100644 client/block-textures.js
create mode 100644 client/dynamics-diagnostics.js
create mode 100644 client/edit-diagnostics.js
create mode 100644 client/edit-mesh-controller.js
create mode 100644 client/edit-mesh-worker.js
create mode 100644 client/edit-mesh.js
create mode 100644 client/light-changes.js
create mode 100644 client/light-properties.js
create mode 100644 client/light-properties.json
create mode 100644 client/lighting-shaders.js
create mode 100644 client/local-light-bounds.js
create mode 100644 client/mesh-geometry.js
create mode 100755 client/physics.wasm
create mode 100644 client/player-interpolation.js
create mode 100644 client/player-physics.js
create mode 100644 client/pointer-lock.js
create mode 100644 client/section-block-map.js
create mode 100644 client/section-stream.js
create mode 100644 client/section-voxel-map.js
create mode 100644 client/shadow-frame.js
create mode 100644 client/terrain-controller.js
create mode 100644 client/terrain-mesh-worker.js
create mode 100644 client/terrain-state.js
create mode 100644 client/terrain-worker.js
create mode 100644 client/tests/actor-rendering.test.js
create mode 100644 client/tests/ambient-occlusion.test.js
create mode 100644 client/tests/block-light-controller.test.js
create mode 100644 client/tests/block-light.test.js
create mode 100644 client/tests/block-textures.test.js
create mode 100644 client/tests/dynamics-diagnostics.test.js
create mode 100644 client/tests/edit-diagnostics.test.js
create mode 100644 client/tests/edit-mesh.test.js
create mode 100644 client/tests/fixtures/lighting-java26.2.json
create mode 100644 client/tests/large-world.test.js
create mode 100644 client/tests/light-changes.test.js
create mode 100644 client/tests/light-properties.test.js
create mode 100644 client/tests/local-terrain-meshing.test.js
create mode 100644 client/tests/mesh-geometry.test.js
create mode 100644 client/tests/physics-wasm.test.js
create mode 100644 client/tests/player-avatar.test.js
create mode 100644 client/tests/player-interpolation.test.js
create mode 100644 client/tests/player-physics.test.js
create mode 100644 client/tests/pointer-lock.test.js
create mode 100644 client/tests/renderer-streaming.test.js
create mode 100644 client/tests/section-block-map.test.js
create mode 100644 client/tests/section-stream.test.js
create mode 100644 client/tests/shadow-frame.test.js
create mode 100644 client/tests/terrain-controller.test.js
create mode 100644 client/tests/terrain-state.test.js
create mode 100644 client/tests/terrain-streaming-smoke.html
create mode 100644 client/tests/terrain-upload.test.js
create mode 100644 client/tests/texture-pack.test.js
create mode 100644 client/tests/view-distance.test.js
create mode 100644 client/tests/world-view.test.js
create mode 100644 client/texture-pack.js
create mode 100644 client/view-distance.js
create mode 100644 client/world-view.js
create mode 100644 crates/shacraft-core/tests/generated.rs
create mode 100644 crates/shacraft-physics/Cargo.toml
create mode 100644 crates/shacraft-physics/src/lib.rs
create mode 100644 crates/shacraft-physics/tests/fixtures/java26.2.json
create mode 100644 crates/shacraft-physics/tests/locomotion.rs
create mode 100644 crates/shacraft-server/src/terrain.rs
create mode 100644 crates/shacraft-server/src/world_streaming.rs
create mode 100644 docs/LIGHTING.md
create mode 100644 docs/PHYSICS.md
create mode 100644 docs/PHYSICS_PLAN.md
create mode 100644 docs/WORLD_STREAMING.md
create mode 100644 docs/WORLD_STREAMING_PLAN.md
create mode 100644 docs/images/minigames-lobby-night.png
create mode 100755 scripts/build_physics.sh
create mode 100644 scripts/check_chunk_streaming.mjs
create mode 100644 scripts/check_player_physics.mjs
create mode 100644 scripts/lighting_reference.java
create mode 100644 scripts/measure_lighting.py
create mode 100644 scripts/measure_physics.py
create mode 100644 scripts/physics_reference.java
create mode 100644 scripts/prepare_texture_pack.py
create mode 100644 scripts/prepare_vanilla_texture_pack.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4371734..59965ad 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,6 +14,7 @@ jobs:
with:
toolchain: 1.96.0
components: rustfmt, clippy
+ targets: wasm32-unknown-unknown
- uses: actions/setup-node@v4
with:
node-version: 22
diff --git a/Cargo.lock b/Cargo.lock
index 099bc83..e2849ba 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1467,6 +1467,14 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "shacraft-physics"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "shacraft-server"
version = "0.1.0"
@@ -1483,6 +1491,7 @@ dependencies = [
"sha2",
"shacraft-content",
"shacraft-core",
+ "shacraft-physics",
"tempfile",
"tokio",
"tower",
diff --git a/Cargo.toml b/Cargo.toml
index c097e8b..b1be0dd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
-members = ["crates/shacraft-core", "crates/shacraft-tools", "crates/shacraft-server", "crates/shacraft-mcp", "crates/shacraft-content", "crates/shacraft-compat"]
+members = ["crates/shacraft-core", "crates/shacraft-tools", "crates/shacraft-server", "crates/shacraft-mcp", "crates/shacraft-content", "crates/shacraft-compat", "crates/shacraft-physics"]
[workspace.package]
version = "0.1.0"
diff --git a/README.md b/README.md
index 980ffb9..c16d7f0 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,12 @@ An independent, open-source voxel engine written in Rust. The local MVP includes
Minecraft is mentioned to identify compatibility targets and data sources. Minecraft names, brands, and assets remain the property of their respective owners. See the [Minecraft Usage Guidelines](https://www.minecraft.net/en-us/usage-guidelines).
+## In-game demonstration
+
+
+
+An imported minigames lobby running in Shacraft, with multiplayer avatars and block lighting. This development screenshot uses original Minecraft textures for a local compatibility test; the world and texture pack are not bundled with the repository. Render distance supports up to 64 chunks (1,024 blocks), with progressive loading across the full world height, Y=−64 through 319.
+
## Quick start
You need Rust 1.96+, a C compiler for bundled SQLite, and a browser with WebGL2. Java and Node.js are not required to play. The first run downloads Cargo dependencies and builds the release binaries; subsequent runs use the existing binaries.
@@ -18,9 +24,10 @@ cd shacraft-core
bash scripts/run.sh --data data --listen 127.0.0.1:4000
```
-Open **http://127.0.0.1:4000**. The server creates a lobby, a block gallery, and two Spleef arenas automatically. The MVP client currently uses Russian interface text.
+Open **http://127.0.0.1:4000**. The server creates a lobby, a block gallery, two Spleef arenas and a procedural Overworld automatically. Open `http://localhost:4000/?world=overworld` for the large world. The MVP client currently uses Russian interface text.
- **WASD** to move, **Space** to jump, and the mouse to look around. Drag to look if pointer lock is unavailable.
+- **Ctrl** to sprint, **Shift** to crouch, **double-Space / F** to toggle permitted creative flight; Space/Shift ascend/descend.
- **Left/right click** to remove/place a block; **E** for the material library, **T** for chat, and **F3** for diagnostics.
- Select a world in the upper-left corner. To play Spleef, two players join the same arena and start a match from the menu.
@@ -29,7 +36,8 @@ World data is stored under `--data`. Stop the server with Ctrl+C. Acknowledged b
## What's included
- **`shacraft-core`** — compact 16³ sections, a bounded shared cache, SQLite with WAL and FULL synchronization, immutable snapshots, independent world edits, revisions, idempotent operations, undo, and reset. No graphics or networking dependencies.
-- **`shacraft-server`** — authoritative movement, collision shapes and interactions, WebSocket/HTTP, persistent entities and settings, and a complete Spleef match cycle.
+- **`shacraft-server`** — authoritative movement, collision shapes and interactions, WebSocket/HTTP, persistent entities and settings, and a complete Spleef match cycle. The seeded Overworld spans 384 vertical layers and nearly ±30 million horizontal blocks, with bounded CPU generation and section streaming.
+- **`shacraft-physics`** — shared 20 Hz Rust/WASM player movement, client prediction and reconciliation, tested against measured Java 26.2 movement kernels, collision cases and block callbacks. See [physics and its limits](docs/PHYSICS.md).
- **`client/`** — a custom renderer, material library, players and entities, chat, world switching, reconnection, and verified package downloads.
- **`shacraft-mcp`** — a separate stdio MCP server with 17 tools, resources, and a prompt; build plans, reads, undo, entities, arenas, metrics, and PNG previews.
- **`shacraft-content`** — **1,196 blocks, 32,366 states, and 158 entity types from Java 26.2**, DataVersion 4903. Source identifiers, measured collision shapes, and independently authored rendering templates. An additional trampoline block demonstrates extensions.
@@ -38,9 +46,9 @@ World data is stored under `--data`. Stop the server with Ctrl+C. Acknowledged b
## Compatibility and scope
-This MVP does not implement full vanilla AI, redstone, inventories, or fluid simulation. Context-dependent shapes are marked separately. After edits, the converter uses an explicit `best-effort` mode; an unchanged original can be returned byte for byte. Full Minecraft gameplay or NeoForge mod compatibility is not claimed. Minecraft source code, binaries, and original visual/audio assets are not distributed.
+This MVP does not implement full vanilla AI, redstone, inventories, or fluid simulation. Context-dependent shapes are marked separately. After edits, the converter uses an explicit `best-effort` mode; an unchanged original can be returned byte for byte. Full Minecraft gameplay or NeoForge mod compatibility is not claimed. Minecraft source code, binaries, texture packs, and audio files are not bundled.
-The release benchmark measured a maximum of **59.65 MiB RSS/VmHWM with 10 moving clients** and **50.50 MiB RSS with 100 idle world forks**. These are results from a short local workload, not a comparison with Paper or a production capacity guarantee. See [verification and limits](docs/VERIFICATION.md).
+The earlier MVP release benchmark measured a maximum of **59.65 MiB RSS/VmHWM with 10 moving clients** and **50.50 MiB RSS with 100 idle world forks**. These measurements predate the shared movement solver and are results from a short local workload, not a comparison with Paper or a production capacity guarantee. See [verification and limits](docs/VERIFICATION.md).
## MCP and world conversion
@@ -61,13 +69,14 @@ The Control API token is created at `data/control.token` with mode 0600 on Unix
```bash
# Rust checks, crash recovery, JavaScript tests, and real HTTP/WebSocket clients:
+rustup target add wasm32-unknown-unknown
bash scripts/verify.sh
# Release benchmark with budgets set before the run:
cargo build --release --workspace --locked
node scripts/benchmark_server.mjs --output artifacts/server-benchmark.json
```
-Node.js 22+ is required for network and JavaScript checks; Python 3 is required for the storage crash test. The independent MCP SDK and Java/NBT verification procedures are documented in [VERIFICATION](docs/VERIFICATION.md). Recorded checks include 84 Rust tests, 6 JavaScript tests, and 14 HTTP/WebSocket scenario groups.
+Node.js 22+ is required for network and JavaScript checks; Python 3 is required for the storage crash test. Verification rebuilds the physics WebAssembly with the Rust `wasm32-unknown-unknown` target; playing uses the included artifact. The independent MCP SDK and Java/NBT verification procedures are documented in [VERIFICATION](docs/VERIFICATION.md).
## Documentation
@@ -75,6 +84,8 @@ Node.js 22+ is required for network and JavaScript checks; Python 3 is required
- [Requirements](docs/REQUIREMENTS.md), [implementation plan](docs/PLAN.md), and [design decisions](docs/DECISIONS.md)
- [Core contracts](docs/CONTRACT.md), [memory and storage](docs/MEMORY_AND_STORAGE.md), and [development tools](docs/DEVELOPMENT.md)
- [Server](docs/SERVER.md), [client](docs/CLIENT.md), and [MCP](docs/MCP.md)
+- [Player physics, reference measurements and prediction](docs/PHYSICS.md)
+- [Procedural worlds, streaming and movement diagnostics](docs/WORLD_STREAMING.md)
- [Content catalog](docs/CONTENT.md), [compatibility](docs/COMPATIBILITY.md), [world interchange](docs/interop.md), and [packages](docs/PACKAGES.md)
- [Acceptance criteria](docs/ACCEPTANCE.md) and [verification evidence](docs/VERIFICATION.md)
diff --git a/client/ambient-occlusion.js b/client/ambient-occlusion.js
new file mode 100644
index 0000000..f557831
--- /dev/null
+++ b/client/ambient-occlusion.js
@@ -0,0 +1,120 @@
+import { unitBox } from "./math.js";
+
+const EPSILON = 1e-4;
+const LEVELS = Object.freeze([0.76, 0.84, 0.92, 1]);
+const OPEN = Object.freeze([1, 1, 1, 1]);
+
+/** Cutout and translucent surfaces should not stamp solid black corners. */
+export function isAmbientOccluder(material) {
+ if (!material) return true; // Match the renderer's unknown-block cube.
+ if (material.transparent || material.cutout || (material.opacity ?? 1) < 1)
+ return false;
+ const name = (material.state ?? "").split("[")[0].split(":").pop();
+ return !/(?:^|_)(?:glass|leaves|sapling|flower|tulip|orchid|bush|roots|fern|vine|vines|torch|lantern|rail|coral|seagrass|kelp)(?:_|$)/.test(name) &&
+ !/^(?:water|lava|ice|frosted_ice|cobweb|scaffolding|iron_bars|chain|ladder|tripwire|redstone_wire|short_grass|tall_grass|dead_bush|sugar_cane|lily_pad|dandelion|poppy|blue_orchid|allium|azure_bluet|oxeye_daisy|cornflower|lily_of_the_valley|wither_rose|sunflower|lilac|rose_bush|peony|azalea|flowering_azalea|brown_mushroom|red_mushroom|crimson_fungus|warped_fungus)$/.test(name);
+}
+
+function validBoxes(material) {
+ return (material?.render ?? [unitBox]).filter((box) =>
+ box?.min?.length === 3 && box?.max?.length === 3 &&
+ box.min.every((value, axis) => Number.isFinite(value) &&
+ Number.isFinite(box.max[axis]) && box.max[axis] > value),
+ );
+}
+
+/**
+ * Create once per section rebuild. Voxel and material lookups are cached until
+ * that rebuild ends; a later edit must use a fresh sampler.
+ *
+ * The returned function accepts (position, localBox, axisNormal, faceVertices).
+ * faceVertices are four normalized 0/1 cube corners, in renderer face.v order.
+ * Values describe ambient light only: direct sunlight remains independent.
+ * Optional isOccluder(material, blockId) can reject texture-pack cutout surfaces.
+ */
+export function createAmbientOcclusionSampler(blocks, materials, isOccluder) {
+ const materialBoxes = new Map(), voxelBoxes = new Map(), offsets = new Map();
+ offsets.set("0,0,0", [0, 0, 0]);
+ const boxesFor = (id) => {
+ if (!materialBoxes.has(id)) {
+ const material = materials.get(id);
+ materialBoxes.set(id,
+ isAmbientOccluder(material) && (!isOccluder || isOccluder(material, id))
+ ? validBoxes(material) : [],
+ );
+ }
+ return materialBoxes.get(id);
+ };
+ // Catalog render boxes can extend beyond their owning voxel (e.g. fences).
+ // Only those actual offsets add lookups; ordinary cubes use a single cell.
+ for (const id of materials.keys())
+ for (const box of boxesFor(id)) {
+ // Unbounded custom geometry must not turn a section rebuild into a scan
+ // of the whole world. The renderer/selection catalog uses this same reach.
+ const min = box.min.map((value) => Math.max(-3, Math.floor(value)));
+ const max = box.max.map((value) => Math.min(3, Math.ceil(value) - 1));
+ for (let x = min[0]; x <= max[0]; x++)
+ for (let y = min[1]; y <= max[1]; y++)
+ for (let z = min[2]; z <= max[2]; z++)
+ offsets.set(`${x},${y},${z}`, [x, y, z]);
+ }
+ const ownerOffsets = [...offsets.values()];
+ const occupied = (point) => {
+ const cellX = Math.floor(point[0]), cellY = Math.floor(point[1]), cellZ = Math.floor(point[2]);
+ for (const offset of ownerOffsets) {
+ const x = cellX - offset[0], y = cellY - offset[1], z = cellZ - offset[2];
+ const key = `${x},${y},${z}`;
+ if (!voxelBoxes.has(key)) {
+ const id = blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(key);
+ voxelBoxes.set(key, id ? boxesFor(id) : []);
+ }
+ const localX = point[0] - x, localY = point[1] - y, localZ = point[2] - z;
+ for (const box of voxelBoxes.get(key))
+ if (localX > box.min[0] && localX < box.max[0] &&
+ localY > box.min[1] && localY < box.max[1] &&
+ localZ > box.min[2] && localZ < box.max[2]) return true;
+ }
+ return false;
+ };
+ return (pos, box, normal, faceVertices) => {
+ const normalAxis = normal.findIndex((value) => Math.abs(value) === 1);
+ if (normalAxis < 0 || normal.some((value, axis) => axis !== normalAxis && value !== 0))
+ return OPEN;
+ const tangents = [0, 1, 2].filter((axis) => axis !== normalAxis);
+ return faceVertices.map((vertex) => {
+ const point = vertex.map((value, axis) =>
+ pos[axis] + box.min[axis] + value * (box.max[axis] - box.min[axis]) +
+ normal[axis] * EPSILON,
+ );
+ const sample = (first, second) => {
+ const candidate = [...point];
+ for (let index = 0; index < 2; index++) {
+ const axis = tangents[index];
+ candidate[axis] += (vertex[axis] < 0.5 ? -1 : 1) *
+ (index === 0 ? first : second) * EPSILON;
+ }
+ return occupied(candidate) ? 1 : 0;
+ };
+ const sideA = sample(1, -1), sideB = sample(-1, 1);
+ // Both sides enclose the corner even when its diagonal cell is empty.
+ const level = sideA && sideB ? 0 : 3 - sideA - sideB - sample(1, 1);
+ return LEVELS[level];
+ });
+ };
+}
+
+/** Mesh sections whose corner lighting can depend on an edited block. */
+export function affectedSectionKeys(pos, renderBoxes = [unitBox]) {
+ const min = [...pos], max = [...pos];
+ for (const box of renderBoxes ?? [unitBox])
+ for (let axis = 0; axis < 3; axis++) {
+ min[axis] = Math.min(min[axis], pos[axis] + Math.floor(box.min[axis]));
+ max[axis] = Math.max(max[axis], pos[axis] + Math.ceil(box.max[axis]) - 1);
+ }
+ const lower = min.map((value) => Math.floor((value - 1) / 16));
+ const upper = max.map((value) => Math.floor((value + 1) / 16));
+ const sections = [];
+ for (let x = lower[0]; x <= upper[0]; x++)
+ for (let y = lower[1]; y <= upper[1]; y++)
+ for (let z = lower[2]; z <= upper[2]; z++) sections.push(`${x},${y},${z}`);
+ return sections;
+}
diff --git a/client/app.js b/client/app.js
index cc9ffde..4091871 100644
--- a/client/app.js
+++ b/client/app.js
@@ -1,4 +1,27 @@
-import { Renderer } from "./renderer.js";
+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,
@@ -12,9 +35,9 @@ const $ = (id) => document.getElementById(id),
const state = {
ws: null,
id: null,
- world: "lobby",
+ world: new URLSearchParams(location.search).get("world") || "lobby",
revision: null,
- blocks: new Map(),
+ blocks: new SectionBlockMap(),
materials: new Map(),
registry: [],
players: new Map(),
@@ -24,6 +47,12 @@ const state = {
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,
@@ -39,8 +68,17 @@ const state = {
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") ||
@@ -73,7 +111,13 @@ function bounceFeedback() {
source.start();
}
}
-let pointerFallback = false;
+let pointerFallback = false,
+ physics = null,
+ prediction = null,
+ motionSupported = false,
+ flyToggle = false,
+ lastSpace = -Infinity;
+const physicsClock = new PhysicsClock();
let renderer,
wsGeneration = 0,
retryTimer,
@@ -89,9 +133,37 @@ let renderer,
held = new Set(),
drag = null,
lastAction = 0,
- lastInput = 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:/, "")
@@ -107,8 +179,10 @@ const bytes = (n) =>
: `${n} Б`
: "—";
const isUIOpen = () =>
- ["menu", "worlds", "library", "diagnostics"].some((id) => !$(id).hidden) ||
+ ["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);
@@ -136,7 +210,11 @@ function send(message) {
}
function controlsZero() {
held.clear();
- if (state.connected)
+ flyToggle = false;
+ if (state.connected && motionSupported) {
+ send({ type: "input_reset" });
+ physicsClock.reset();
+ } else if (state.connected)
send({
type: "input",
seq: ++state.seq,
@@ -145,18 +223,103 @@ function controlsZero() {
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", "diagnostics"])
+ for (const id of ["menu", "worlds", "library"])
$(id).hidden = true;
}
function panel(id) {
const wasOpen = !$(id).hidden;
- closePanels();
- document.exitPointerLock?.();
+ if (id !== "diagnostics") closePanels();
+ pointerLock.release();
controlsZero();
$(id).hidden = wasOpen;
+ if (wasOpen) canvas.focus();
if (!wasOpen && id === "library") {
loadCatalog(true);
$("catalog-search").focus();
@@ -165,7 +328,7 @@ function panel(id) {
if (!wasOpen && id === "diagnostics") fetchMetrics();
}
function release() {
- document.exitPointerLock?.();
+ pointerLock.release();
controlsZero();
}
function acquire() {
@@ -176,22 +339,12 @@ function acquire() {
toast("Соединение с сервером ещё не установлено.");
return;
}
- try {
- const p = canvas.requestPointerLock?.();
- p?.catch?.(() => {
- pointerFallback = true;
- toast(
- "Захват мыши недоступен: перетаскивайте для обзора, щёлкните для правки.",
- );
- });
- } catch {
- pointerFallback = true;
- toast("Для обзора удерживайте кнопку мыши и двигайте курсор.");
- }
canvas.focus();
+ if (!pointerFallback) pointerLock.request();
}
-function addMaterials(items = []) {
- let changed = false;
+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),
@@ -214,29 +367,68 @@ function addMaterials(items = []) {
if (style?.color) mat.color = style.color;
mat.effect = style?.effect;
state.materials.set(mat.id, mat);
- if (!prev) changed = true;
+ if (!prev) added.add(item.id);
}
- return changed;
+ 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);
- state.blocks = new Map();
- for (const b of m.blocks || [])
- if (b.block) state.blocks.set(key(b.pos), b.block);
+ 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;
- if (m.spawn) {
+ 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 (m.players) updatePlayers(m.players, true);
+ 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;
- renderer.replace(state.blocks, state.materials);
+ 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") {
@@ -254,6 +446,11 @@ function updatePlayers(players, instant = false) {
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]),
@@ -277,42 +474,81 @@ function updatePlayers(players, instant = false) {
continue;
}
const old = state.players.get(p.id);
- state.players.set(p.id, {
- ...p,
- position: old && !instant ? old.position : [...p.position],
- target: [...p.position],
- });
+ 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) {
- const center = state.viewCenter;
- return (
- !center ||
- pos.every(
- (v, i) => v >= center[i] - (i === 1 ? 8 : 32) && v <= center[i] + 31,
- )
- );
+ 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) {
- state.resync = true;
- send({ type: "resync" });
- toast("Сверяем изменения с сервером…");
+ 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)));
+ 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) {
@@ -324,6 +560,8 @@ function onMessage(m) {
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);
@@ -331,6 +569,11 @@ function onMessage(m) {
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;
@@ -370,6 +613,14 @@ async function getJSON(url) {
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(
@@ -378,6 +629,10 @@ async function verifyPackages(manifest, generation) {
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");
@@ -433,28 +688,38 @@ async function verifyPackages(manifest, generation) {
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);
- $("package-status").textContent =
- `${state.packFiles} файлов проверено · ${bytes(state.packBytes)} · из кэша ${state.packCached}`;
+ updatePackageStatus();
connection(`Пакеты ${state.packFiles}`);
}
// Resolve declared references only after every resource passed integrity checks.
- // The supported client hook is declarative `effect: bounce`, with no package JS.
+ // 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);
@@ -473,9 +738,7 @@ async function verifyPackages(manifest, generation) {
}
}
}
- if (!state.packFiles)
- $("package-status").textContent =
- "Сервер не требует дополнительных файлов.";
+ updatePackageStatus();
if (packageCache) {
const keys = await packageCache.keys();
if (keys.length > 512)
@@ -494,6 +757,9 @@ async function connect() {
state.id = null;
state.players.clear();
state.seq = 0;
+ prediction?.reset();
+ motionSupported = false;
+ physicsClock.reset();
connection("Подключение");
controlsZero();
try {
@@ -513,15 +779,23 @@ async function connect() {
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 {
- onMessage(JSON.parse(event.data));
+ 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);
@@ -577,7 +851,7 @@ async function loadWorlds() {
const title = document.createElement("strong");
title.textContent = world.name;
const subtitle = document.createElement("small");
- subtitle.textContent = `Ревизия ${world.revision}${world.template ? ` · экземпляр ${world.template}` : ""}`;
+ 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) {
@@ -600,7 +874,7 @@ async function loadMissingMaterials() {
missingPending = true;
try {
while (state.connected) {
- const missing = [...new Set(state.blocks.values())]
+ 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;
@@ -609,15 +883,6 @@ async function loadMissingMaterials() {
);
const items = data.items || data;
if (!addMaterials(items)) break;
- const loaded = new Set(items.map((material) => material.id));
- renderer.change(
- [...state.blocks]
- .filter(([, id]) => loaded.has(id))
- .map(([position, block]) => ({
- pos: position.split(",").map(Number),
- block,
- })),
- );
await new Promise((resolve) => setTimeout(resolve, 0));
}
seedHotbar();
@@ -772,31 +1037,27 @@ function showMatch(m) {
`${phases[m.phase] || m.phase}${m.remaining !== undefined ? ` · ${Math.ceil(m.remaining)} с` : ""}${winner}`;
}
function action(kind) {
- if (!state.connected || isUIOpen()) return;
+ if (!state.connected || isUIFocused()) return;
const hit = state.selection;
if (!hit) return;
const now = performance.now();
if (now - lastAction < 130) return;
lastAction = now;
- send({
- type: "input",
- seq: ++state.seq,
- yaw: state.yaw,
- pitch: state.pitch,
- forward: (held.has("KeyW") ? 1 : 0) - (held.has("KeyS") ? 1 : 0),
- strafe: (held.has("KeyD") ? 1 : 0) - (held.has("KeyA") ? 1 : 0),
- jump: held.has("Space"),
- });
- if (kind === "break") send({ type: "break", pos: hit.pos });
+ 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;
- send({
+ const pos = hit.pos.map((v, i) => v + hit.normal[i]);
+ if (send({
type: "place",
- pos: hit.pos.map((v, i) => v + hit.normal[i]),
+ pos,
block: mat.id,
state: mat.state,
- });
+ })) editDiagnostics.request(pos, mat.id, now);
} else if (kind === "pick") {
state.hotbar[state.slot] = hit.block;
renderHotbar();
@@ -810,7 +1071,51 @@ function openChat() {
}
$("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);
@@ -853,7 +1158,7 @@ $("chat-form").onsubmit = (e) => {
canvas.focus();
};
window.addEventListener("keydown", (e) => {
- const editing = /INPUT|TEXTAREA/.test(document.activeElement?.tagName);
+ const editing = /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName);
if (e.code === "Escape") {
if (!$("chat-form").hidden) {
$("chat-form").hidden = true;
@@ -865,7 +1170,8 @@ window.addEventListener("keydown", (e) => {
controlsZero();
return;
}
- if (editing) return;
+ if (e.code === "F3") { e.preventDefault(); if (!e.repeat) panel("diagnostics"); return; }
+ if (editing || (!$("diagnostics").hidden && $("diagnostics").contains(document.activeElement))) return;
if (
[
"Space",
@@ -879,6 +1185,10 @@ window.addEventListener("keydown", (e) => {
"ArrowRight",
"Tab",
"F3",
+ "ControlLeft",
+ "ControlRight",
+ "ShiftLeft",
+ "ShiftRight",
].includes(e.code)
)
e.preventDefault();
@@ -887,10 +1197,6 @@ window.addEventListener("keydown", (e) => {
panel("library");
return;
}
- if (e.code === "F3") {
- panel("diagnostics");
- return;
- }
if (e.code === "KeyT" || e.code === "Enter") {
openChat();
return;
@@ -900,7 +1206,17 @@ window.addEventListener("keydown", (e) => {
renderHotbar();
return;
}
- if (!isUIOpen()) held.add(e.code);
+ 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", () => {
@@ -908,12 +1224,9 @@ window.addEventListener("blur", () => {
controlsZero();
});
document.addEventListener("visibilitychange", () => {
- if (document.hidden) controlsZero();
-});
-document.addEventListener("pointerlockchange", () => {
- const locked = document.pointerLockElement === canvas;
- document.body.classList.toggle("locked", locked);
- if (!locked) controlsZero();
+ if (document.hidden) {dynamics.stop(performance.now(),'tab-hidden');controlsZero();}
+ physicsClock.reset();
+ lastFrame = performance.now();
});
function look(dx, dy) {
const sensitivity = Number($("sensitivity").value);
@@ -932,13 +1245,16 @@ 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,
@@ -955,7 +1271,6 @@ canvas.addEventListener("pointermove", (e) => {
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) > 3)
drag.moved = true;
if (drag.moved) {
- pointerFallback = true;
look(dx, dy);
}
drag.x = e.clientX;
@@ -965,6 +1280,7 @@ 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");
@@ -985,10 +1301,12 @@ canvas.addEventListener(
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 || {},
@@ -1001,11 +1319,47 @@ function updateDiagnostics() {
["Сущности", 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)],
@@ -1014,6 +1368,12 @@ function updateDiagnostics() {
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) {
@@ -1024,12 +1384,45 @@ function updateDiagnostics() {
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() {
@@ -1049,7 +1442,7 @@ function updateLabels() {
label.textContent = p.name;
const at = renderer.project([
p.position[0],
- p.position[1] + 1.95,
+ p.position[1] + (p.height ?? 1.8) + 0.15,
p.position[2],
]);
label.hidden =
@@ -1065,17 +1458,48 @@ function updateLabels() {
}
}
function frame(time) {
- const dt = Math.min(0.05, (time - lastFrame) / 1000);
+ 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);
- for (let i = 0; i < 3; i++)
- state.position[i] += (state.target[i] - state.position[i]) * blend;
- for (const p of state.players.values())
+ 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++)
- p.position[i] += (p.target[i] - p.position[i]) * blend;
+ 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] + 1.62, state.position[2]],
+ eye: [
+ state.position[0],
+ state.position[1] + state.eyeHeight,
+ state.position[2],
+ ],
yaw: state.yaw,
pitch: state.pitch,
};
@@ -1099,53 +1523,88 @@ function frame(time) {
)
: "";
}
+ const drawStart=performance.now();
renderer.draw(
camera,
[...state.players.values()],
state.entities,
state.entityDefs,
);
+ const drawEnd=performance.now();
updateLabels();
- if (state.connected && time - lastInput >= 50) {
- lastInput = time;
- let forward = isUIOpen()
- ? 0
- : (held.has("KeyW") || held.has("ArrowUp") ? 1 : 0) -
- (held.has("KeyS") || held.has("ArrowDown") ? 1 : 0),
- strafe = isUIOpen()
- ? 0
- : (held.has("KeyD") || held.has("ArrowRight") ? 1 : 0) -
- (held.has("KeyA") || held.has("ArrowLeft") ? 1 : 0);
- send({
- type: "input",
- seq: ++state.seq,
- yaw: state.yaw,
- pitch: state.pitch,
- forward,
- strafe,
- jump: !isUIOpen() && held.has("Space"),
- });
+ 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);
}
-try {
- renderer = new Renderer(canvas);
- renderHotbar();
- connect();
- requestAnimationFrame(frame);
- setInterval(() => {
- if (state.connected) send({ type: "ping", client_time: performance.now() });
- if (!$("diagnostics").hidden) fetchMetrics();
- }, 3000);
-} catch (error) {
- console.error(error);
- $("fatal").textContent = error.message;
- $("fatal").hidden = false;
- connection("Ошибка графики", true);
+$("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();
diff --git a/client/block-light-controller.js b/client/block-light-controller.js
new file mode 100644
index 0000000..8b524aa
--- /dev/null
+++ b/client/block-light-controller.js
@@ -0,0 +1,104 @@
+import { attachBlockLightSamplers, buildBlockLight } from "./block-light.js";
+import { applyLightProperties, loadLightProperties } from "./light-properties.js";
+
+/** Only one build runs at a time. Edits arriving during a build replace its result. */
+export class BlockLightController {
+ constructor(publish) {
+ this.publish = publish;
+ this.generation = 0;
+ this.busy = false;
+ this.pending = null;
+ this.status = "loading";
+ this.properties = null;
+ this.worker = null;
+ if (typeof Worker === "function") {
+ try {
+ this.worker = new Worker(new URL("./block-light-worker.js", import.meta.url), { type: "module" });
+ } catch {
+ // Restricted browser contexts can reject workers before any error event.
+ // The same solver remains available on the main thread.
+ }
+ }
+ if (this.worker) {
+ this.worker.onmessage = ({ data }) => this.receive(data);
+ this.worker.onerror = (event) => {
+ event.preventDefault();
+ this.worker.terminate();
+ this.worker = null;
+ this.busy = false;
+ // Preserve the latest request and recover with the same implementation.
+ this.pending ||= this.current;
+ this.start();
+ };
+ }
+ loadLightProperties().then(properties => {
+ this.properties = properties;
+ this.start();
+ }).catch(error => {
+ this.status = `error: ${error.message}`;
+ console.error("Shacraft light metadata", error);
+ });
+ }
+ request(blocks, materials, bounds) {
+ this.generation++;
+ this.pending = { blocks, materials, bounds, generation: this.generation };
+ this.status = "pending";
+ if (!this.timer) this.timer = setTimeout(() => { this.timer = null; this.start(); }, 25);
+ }
+ start() {
+ if (this.busy || !this.pending || !this.properties) return;
+ const request = this.pending;
+ this.pending = null;
+ this.busy = true;
+ this.current = request;
+ this.status = "building";
+ const materials = new Map([...request.materials].map(([id, mat]) => [id,
+ applyLightProperties({
+ minecraft_id: mat.minecraft_id, state: mat.state, light: mat.light,
+ opacity: mat.opacity, render: mat.render, transparent: mat.transparent,
+ light_dampening: mat.light_dampening, light_occlusion: mat.light_occlusion,
+ }, this.properties)]));
+ const packet = { generation: request.generation, bounds: request.bounds,
+ blocks: [...request.blocks], materials: [...materials] };
+ this.current = { ...request, blocks: new Map(packet.blocks), materials };
+ if (this.worker) this.worker.postMessage(packet);
+ else setTimeout(() => {
+ const start = performance.now();
+ try {
+ this.receive({ generation: request.generation,
+ field: buildBlockLight(this.current.blocks, materials, request.bounds),
+ milliseconds: performance.now() - start });
+ } catch (error) { this.receive({ generation: request.generation, error: error.message }); }
+ }, 0);
+ }
+ receive(message) {
+ const current = this.current;
+ if (!this.busy || message.generation !== current?.generation) return;
+ this.busy = false;
+ if (message.generation === this.generation) {
+ if (message.error) {
+ this.status = `error: ${message.error}`;
+ console.error("Shacraft block lighting", message.error);
+ } else {
+ this.status = "ready";
+ this.milliseconds = message.milliseconds;
+ this.publish(attachBlockLightSamplers(message.field, current.blocks, current.materials));
+ }
+ }
+ this.start();
+ }
+}
+
+/** In a standalone scene, keep a full light-radius margin around its geometry. */
+export function inferLightBounds(blocks) {
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
+ for (const key of blocks.keys()) {
+ const pos = key.split(",").map(Number);
+ for (let axis = 0; axis < 3; axis++) {
+ min[axis] = Math.min(min[axis], pos[axis]);
+ max[axis] = Math.max(max[axis], pos[axis]);
+ }
+ }
+ if (!Number.isFinite(min[0])) return { min: [-16, -16, -16], max: [16, 16, 16] };
+ return { min: min.map(v => v - 15), max: max.map(v => v + 15) };
+}
diff --git a/client/block-light-worker.js b/client/block-light-worker.js
new file mode 100644
index 0000000..d40c57e
--- /dev/null
+++ b/client/block-light-worker.js
@@ -0,0 +1,15 @@
+import { buildBlockLight } from "./block-light.js";
+
+self.onmessage = ({ data: request }) => {
+ try {
+ const start = performance.now();
+ const result = buildBlockLight(new Map(request.blocks), new Map(request.materials), request.bounds);
+ const { min, size, data, sourceCount, blockLevels, skyLevels } = result;
+ self.postMessage({ generation: request.generation,
+ field: { min, size, data, sourceCount, blockLevels, skyLevels },
+ milliseconds: performance.now() - start,
+ }, [data.buffer, blockLevels.buffer, skyLevels.buffer]);
+ } catch (error) {
+ self.postMessage({ generation: request.generation, error: error.message });
+ }
+};
diff --git a/client/block-light.js b/client/block-light.js
new file mode 100644
index 0000000..14b384f
--- /dev/null
+++ b/client/block-light.js
@@ -0,0 +1,257 @@
+import { isAmbientOccluder } from "./ambient-occlusion.js";
+import { unitBox } from "./math.js";
+import { MAX_LIGHT_CELLS } from "./view-distance.js";
+
+const MAX_CELLS = MAX_LIGHT_CELLS;
+const EPSILON = 1e-4;
+const DIRECTIONS = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];
+const OPPOSITE = [1, 0, 3, 2, 5, 4];
+const ZERO = Object.freeze([0, 0, 0]);
+const DARK = Object.freeze({ block: ZERO, sky: 0, blockLevel: 0, skyLevel: 0 });
+const integerTriple = (value) => Array.isArray(value) && value.length === 3 && value.every(Number.isSafeInteger);
+const lightLevel = (value) => Number.isFinite(value) ? Math.max(0, Math.min(15, Math.floor(value))) : 0;
+
+/** Canonical source strength comes exclusively from the authoritative state. */
+export function blockEmission(material) {
+ const level = lightLevel(material?.light);
+ const state = (material?.state ?? "").split("[")[0].split(":").pop();
+ let color = [1, 0.82, 0.57];
+ if (/^soul_|soul_fire/.test(state)) color = [0.3, 0.82, 1];
+ else if (/sea_lantern|end_rod|beacon/.test(state)) color = [0.76, 0.9, 1];
+ else if (/redstone_(?:wall_)?torch|redstone_ore/.test(state)) color = [1, 0.28, 0.13];
+ else if (/amethyst|crying_obsidian|respawn_anchor/.test(state)) color = [0.69, 0.42, 1];
+ else if (/warped|glow_lichen|verdant_froglight/.test(state)) color = [0.54, 1, 0.79];
+ else if (/pearlescent_froglight/.test(state)) color = [1, 0.72, 0.9];
+ else if (/lava|fire|campfire|furnace|magma/.test(state)) color = [1, 0.56, 0.24];
+ else if (/torch|lantern|candle|glowstone|shroomlight|jack_o_lantern/.test(state)) color = [1, 0.75, 0.43];
+ return { level, color };
+}
+
+function boxesOf(value) {
+ return (value ?? []).filter((box) => box?.min?.length === 3 && box?.max?.length === 3 &&
+ box.min.every((min, axis) => Number.isFinite(min) && Number.isFinite(box.max[axis]) && box.max[axis] > min));
+}
+
+function faceRectangles(boxes, direction) {
+ const axis = direction >> 1, positive = direction % 2 === 0;
+ const tangents = [0, 1, 2].filter((value) => value !== axis);
+ const rectangles = [];
+ for (const box of boxes) {
+ if (positive ? box.max[axis] < 1 - EPSILON : box.min[axis] > EPSILON) continue;
+ const rect = [
+ Math.max(0, box.min[tangents[0]]), Math.max(0, box.min[tangents[1]]),
+ Math.min(1, box.max[tangents[0]]), Math.min(1, box.max[tangents[1]]),
+ ];
+ if (rect[2] > rect[0] && rect[3] > rect[1]) rectangles.push(rect);
+ }
+ return rectangles;
+}
+
+/** Exact union coverage of axis-aligned boundary rectangles, including slabs. */
+function coversFace(rectangles) {
+ if (!rectangles.length) return false;
+ if (rectangles.some((r) => r[0] === 0 && r[1] === 0 && r[2] === 1 && r[3] === 1)) return true;
+ const xs = [...new Set([0, 1, ...rectangles.flatMap((r) => [r[0], r[2]])])].sort((a, b) => a - b);
+ const ys = [...new Set([0, 1, ...rectangles.flatMap((r) => [r[1], r[3]])])].sort((a, b) => a - b);
+ for (let x = 1; x < xs.length; x++)
+ for (let y = 1; y < ys.length; y++) {
+ const midX = (xs[x - 1] + xs[x]) / 2, midY = (ys[y - 1] + ys[y]) / 2;
+ if (!rectangles.some((r) => midX > r[0] && midX < r[2] && midY > r[1] && midY < r[3])) return false;
+ }
+ return true;
+}
+
+function materialDescriptors(materials) {
+ const air = { index: 0, dampening: 0, boxes: [], faces: Array.from({ length: 6 }, () => []), fullFaces: 0, emission: { level: 0, color: ZERO } };
+ const descriptors = [air], byId = new Map(), faceCache = new Map();
+ const get = (id) => {
+ if (!id) return air;
+ if (!byId.has(id)) {
+ const material = materials.get(id);
+ const render = boxesOf(material?.render ?? [unitBox]);
+ const opaque = isAmbientOccluder(material);
+ const fullCube = opaque && coversFace(faceRectangles(render, 0)) && coversFace(faceRectangles(render, 1)) &&
+ coversFace(faceRectangles(render, 2)) && coversFace(faceRectangles(render, 3)) &&
+ coversFace(faceRectangles(render, 4)) && coversFace(faceRectangles(render, 5));
+ const emission = blockEmission(material);
+ const measuredDampening = material?.light_dampening ?? material?.light_block;
+ const dampening = Number.isFinite(measuredDampening) ? lightLevel(measuredDampening) : fullCube ? 15 : 0;
+ // Effective Java light-occlusion shapes are already empty for ordinary
+ // full cubes and transparent blocks. Opacity handles ordinary solid cubes.
+ const boxes = boxesOf(material?.light_occlusion ?? (opaque && !fullCube ? render : []));
+ const faces = DIRECTIONS.map((_, direction) => faceRectangles(boxes, direction));
+ const fullFaces = faces.reduce((mask, rects, direction) => mask | (coversFace(rects) ? 1 << direction : 0), 0);
+ const descriptor = { index: descriptors.length, dampening, boxes: opaque ? render : [], faces, fullFaces, emission };
+ descriptors.push(descriptor);
+ byId.set(id, descriptor);
+ }
+ return byId.get(id);
+ };
+ const blocked = (source, target, direction) => {
+ if ((source.fullFaces & (1 << direction)) || (target.fullFaces & (1 << OPPOSITE[direction]))) return true;
+ const first = source.faces[direction], second = target.faces[OPPOSITE[direction]];
+ if (!first.length || !second.length) return false;
+ const key = `${source.index},${target.index},${direction}`;
+ if (!faceCache.has(key)) faceCache.set(key, coversFace([...first, ...second]));
+ return faceCache.get(key);
+ };
+ return { get, descriptors, blocked };
+}
+
+function validateBounds(bounds) {
+ if (!integerTriple(bounds?.min) || !integerTriple(bounds?.max)) throw new RangeError("Block-light bounds must contain integer min/max triples");
+ const min = [...bounds.min], size = bounds.max.map((value, axis) => value - min[axis] + 1);
+ const count = size[0] * size[1] * size[2];
+ if (size.some((value) => value <= 0 || !Number.isSafeInteger(value)) || !Number.isSafeInteger(count) || count > MAX_CELLS)
+ throw new RangeError(`Block-light bounds must contain 1 to ${MAX_CELLS} cells`);
+ return { min, size, count };
+}
+
+/**
+ * Bounded, deterministic Java-style max-light propagation. Bounds are inclusive;
+ * x is the fastest axis: x + size[0] * (y + size[1] * z).
+ *
+ * RGB is a visual tint of the winning canonical source. Equal-strength sources
+ * combine their tint by channel maximum, never adding to block-light levels.
+ * Sky above the supplied top boundary is treated as exposed sky; unknown side
+ * and bottom boundaries are closed. The caller supplies the full loaded view.
+ */
+export function buildBlockLight(blocks, materials, bounds, skyColumns = null) {
+ const { min, size, count } = validateBounds(bounds);
+ const [sx, sy, sz] = size, plane = sx * sy;
+ const data = new Uint8Array(count * 4), blockLevels = new Uint8Array(count), skyLevels = new Uint8Array(count);
+ const types = new Uint32Array(count), definitions = materialDescriptors(materials);
+ const blockBuckets = Array.from({ length: 16 }, () => []), skyBuckets = Array.from({ length: 16 }, () => []);
+ let sourceCount = 0;
+ const addBlock = (wx,wy,wz,id) => {
+ const x=wx-min[0], y=wy-min[1], z=wz-min[2];
+ if(x<0||x>=sx||y<0||y>=sy||z<0||z>=sz)return;
+ const index=x+sx*(y+sy*z), descriptor=definitions.get(id);
+ types[index]=descriptor.index;
+ const {level,color}=descriptor.emission;
+ if(!level)return;
+ sourceCount++;blockLevels[index]=level;
+ for(let channel=0;channel<3;channel++)data[index*4+channel]=Math.round(color[channel]*255);
+ blockBuckets[level].push(index);
+ };
+ if(blocks.forEachBlock) blocks.forEachBlock(addBlock,bounds);
+ else for(const [key,id] of blocks) if(id) addBlock(...key.split(",").map(Number),id);
+ for (let z = 0; z < sz; z++)
+ for (let x = 0; x < sx; x++) {
+ if (skyColumns) {
+ const wx=x+min[0],wz=z+min[2], column=skyColumns.get(`${Math.floor(wx/16)},${Math.floor(wz/16)}`);
+ const height=column?.[((wx%16+16)%16)+16*((wz%16+16)%16)] ?? 32767;
+ if(height>=min[1]+sy)continue;
+ }
+ const index = x + sx * (sy - 1 + sy * z), descriptor = definitions.descriptors[types[index]];
+ if (descriptor.dampening >= 15 || descriptor.fullFaces & (1 << 2)) continue;
+ const value = 15 - descriptor.dampening;
+ if (!value) continue;
+ skyLevels[index] = value;
+ skyBuckets[value].push(index);
+ }
+ const offsets = [1, -1, sx, -sx, plane, -plane];
+ const propagate = (levels, buckets, sky) => {
+ for (let level = 15; level > 0; level--) {
+ const bucket = buckets[level];
+ for (let cursor = 0; cursor < bucket.length; cursor++) {
+ const index = bucket[cursor];
+ if (levels[index] !== level) continue;
+ const x = index % sx, y = Math.floor(index / sx) % sy, z = Math.floor(index / plane);
+ const source = definitions.descriptors[types[index]];
+ for (let direction = 0; direction < 6; direction++) {
+ if ((direction === 0 && x === sx - 1) || (direction === 1 && x === 0) ||
+ (direction === 2 && y === sy - 1) || (direction === 3 && y === 0) ||
+ (direction === 4 && z === sz - 1) || (direction === 5 && z === 0)) continue;
+ const nextIndex = index + offsets[direction], target = definitions.descriptors[types[nextIndex]];
+ const attenuation = sky && level === 15 && direction === 3 && target.dampening === 0 ? 0 : Math.max(1, target.dampening);
+ const value = level - attenuation, previous = levels[nextIndex];
+ if (value <= 0 || value < previous || (sky && value === previous) || definitions.blocked(source, target, direction)) continue;
+ if (value > previous) {
+ levels[nextIndex] = value;
+ buckets[value].push(nextIndex);
+ if (!sky) for (let channel = 0; channel < 3; channel++) data[nextIndex * 4 + channel] = data[index * 4 + channel];
+ } else if (!sky) {
+ // All parents at level + 1 are finalized before this level is
+ // propagated, so equal-strength tint changes need no extra queue.
+ for (let channel = 0; channel < 3; channel++)
+ data[nextIndex * 4 + channel] = Math.max(data[nextIndex * 4 + channel], data[index * 4 + channel]);
+ }
+ }
+ }
+ }
+ };
+ propagate(blockLevels, blockBuckets, false);
+ propagate(skyLevels, skyBuckets, true);
+ for (let index = 0; index < count; index++) {
+ const level = blockLevels[index], brightness = (level / 15) ** 2;
+ for (let channel = 0; channel < 3; channel++) data[index * 4 + channel] = Math.round(data[index * 4 + channel] * brightness);
+ data[index * 4 + 3] = level;
+ }
+ return attachBlockLightSamplers({ min, size, data, sourceCount, blockLevels, skyLevels }, blocks, materials);
+}
+
+/** Restore cheap sampling methods after worker ArrayBuffer transfer. */
+export function attachBlockLightSamplers(field, blocks, materials) {
+ const { min, size, data, blockLevels, skyLevels } = field;
+ const definitions = materialDescriptors(materials), voxelCache = new Map(), sampleCache = new Map();
+ const indexAt = (point) => {
+ const x = Math.floor(point[0]) - min[0], y = Math.floor(point[1]) - min[1], z = Math.floor(point[2]) - min[2];
+ return x < 0 || x >= size[0] || y < 0 || y >= size[1] || z < 0 || z >= size[2] ? -1 : x + size[0] * (y + size[1] * z);
+ };
+ const voxelAt = (point) => {
+ const x = Math.floor(point[0]), y = Math.floor(point[1]), z = Math.floor(point[2]);
+ const index = x - min[0] + size[0] * (y - min[1] + size[1] * (z - min[2]));
+ if (!voxelCache.has(index)) voxelCache.set(index, { pos: [x, y, z], descriptor: definitions.get(blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(`${x},${y},${z}`)) });
+ return voxelCache.get(index);
+ };
+ const freeAt = (point) => {
+ if (indexAt(point) < 0) return false;
+ const { pos, descriptor } = voxelAt(point);
+ return !descriptor.boxes.some((box) => point.every((value, axis) => value - pos[axis] > box.min[axis] && value - pos[axis] < box.max[axis]));
+ };
+ const canSampleFrom = (point, anchor) => {
+ if (indexAt(anchor) < 0 || !freeAt(point)) return false;
+ const target = voxelAt(point), source = voxelAt(anchor);
+ let direction = -1, distance = 0;
+ for (let axis = 0; axis < 3; axis++) {
+ const delta = target.pos[axis] - source.pos[axis];
+ if (delta) { direction = axis * 2 + (delta < 0 ? 1 : 0); distance += Math.abs(delta); }
+ }
+ return distance === 0 || (distance === 1 && !definitions.blocked(source.descriptor, target.descriptor, direction));
+ };
+ field.sample = (point) => {
+ const index = indexAt(point);
+ if (index < 0) return DARK;
+ if (!sampleCache.has(index)) sampleCache.set(index,
+ { block: [data[index * 4] / 255, data[index * 4 + 1] / 255, data[index * 4 + 2] / 255],
+ sky: skyLevels[index] / 15, blockLevel: blockLevels[index], skyLevel: skyLevels[index] });
+ return sampleCache.get(index);
+ };
+ field.sampleFace = (pos, box, normal, vertices) => {
+ const axis = normal.findIndex((value) => Math.abs(value) === 1), tangents = [0, 1, 2].filter((value) => value !== axis);
+ const result = { block: [], sky: [], blockLevels: [], skyLevels: [] };
+ for (const vertex of vertices) {
+ const corner = vertex.map((value, a) => pos[a] + box.min[a] + value * (box.max[a] - box.min[a]) + normal[a] * EPSILON);
+ const offset = (u, v) => {
+ const point = [...corner];
+ for (let i = 0; i < 2; i++) point[tangents[i]] += (vertex[tangents[i]] < 0.5 ? -1 : 1) * (i ? v : u) * EPSILON;
+ return point;
+ };
+ // Begin just inside this face's tangent bounds. Interpolate only toward
+ // accessible neighboring cells, never through an opaque corner or wall.
+ const base = offset(-1, -1), first = offset(1, -1), second = offset(-1, 1), diagonal = offset(1, 1);
+ const baseSample = freeAt(base) ? field.sample(base) : DARK;
+ const firstOpen = canSampleFrom(first, base), secondOpen = canSampleFrom(second, base);
+ const diagonalOpen = (firstOpen && canSampleFrom(diagonal, first)) || (secondOpen && canSampleFrom(diagonal, second));
+ const samples = [baseSample, firstOpen ? field.sample(first) : baseSample,
+ secondOpen ? field.sample(second) : baseSample, diagonalOpen ? field.sample(diagonal) : baseSample];
+ result.block.push([0, 1, 2].map((channel) => samples.reduce((sum, sample) => sum + sample.block[channel], 0) / 4));
+ result.sky.push(samples.reduce((sum, sample) => sum + sample.sky, 0) / 4);
+ result.blockLevels.push(samples.reduce((sum, sample) => sum + sample.blockLevel, 0) / 4);
+ result.skyLevels.push(samples.reduce((sum, sample) => sum + sample.skyLevel, 0) / 4);
+ }
+ return result;
+ };
+ return field;
+}
diff --git a/client/block-textures.js b/client/block-textures.js
new file mode 100644
index 0000000..afc7251
--- /dev/null
+++ b/client/block-textures.js
@@ -0,0 +1,101 @@
+import { BLOCK_FACES } from "./texture-pack.js";
+
+const WHITE = Object.freeze([1, 1, 1]);
+const GRASS = Object.freeze([0.58, 0.8, 0.34]);
+const LEAVES = Object.freeze([0.46, 0.7, 0.28]);
+
+// These aliases share a surface material. Similar block names are deliberately
+// not enough: stripped logs, other wood species and deepslate ores need assets.
+const materials = new Map([
+ ["stone", ["stone", "stone_stairs", "stone_slab", "stone_button", "stone_pressure_plate"]],
+ ["cobblestone", ["cobblestone", "cobblestone_stairs", "cobblestone_slab", "cobblestone_wall"]],
+ ["oak_planks", ["oak_planks", "oak_stairs", "oak_slab", "oak_fence", "oak_fence_gate", "oak_button", "oak_pressure_plate"]],
+ ["bricks", ["bricks", "brick_stairs", "brick_slab", "brick_wall"]],
+ ["stone_bricks", ["stone_bricks", "stone_brick_stairs", "stone_brick_slab", "stone_brick_wall"]],
+ ["glass", ["glass", "glass_pane"]],
+ ["snow", ["snow", "snow_block"]],
+ ...["dirt", "sand", "gravel", "diamond_ore", "iron_ore", "coal_ore", "gold_ore", "redstone_ore", "deepslate", "obsidian", "netherrack"].map((name) => [name, [name]]),
+].flatMap(([texture, blocks]) => blocks.map((block) => [block, texture])));
+
+/** Face order matches renderer.js: east, west, up, down, south, north. */
+export function getBlockFaceTextures(state, layers) {
+ const empty = () => Array(6).fill(null);
+ const match = /^(?:minecraft:)?([a-z0-9_]+)(?:\[([^\]]*)\])?$/.exec(state ?? "");
+ if (!match) return empty();
+ const [, name, properties = ""] = match;
+ const props = Object.fromEntries(
+ properties.split(",").filter(Boolean).map((entry) => entry.split("=")),
+ );
+ const mapping = layers.get(BLOCK_FACES);
+ if (mapping) {
+ const base = `minecraft:${name}`;
+ const canonical = base + (properties ? `[${Object.entries(props).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join(",")}]` : "");
+ const index = Object.hasOwn(mapping.states, canonical) ? mapping.states[canonical] : mapping.defaults[base];
+ if (Number.isInteger(index)) return mapping.sets[index];
+ }
+ const face = (texture, tint = WHITE, cutout = false) => {
+ const layer = layers.get(texture);
+ return Number.isInteger(layer) && layer >= 0 ? { layer, tint, cutout } : null;
+ };
+ const all = (texture, tint = WHITE, cutout = false) =>
+ Array.from({ length: 6 }, () => face(texture, tint, cutout));
+
+ if (name === "grass_block") {
+ const side = props.snowy === "true" ? null : "grass_block_side";
+ return [side, side, "grass_block_top", "dirt", side, side].map(
+ (texture, index) => texture ? face(texture, index === 2 ? GRASS : WHITE) : null,
+ );
+ }
+ if (name === "oak_log") {
+ const caps = { x: [0, 1], y: [2, 3], z: [4, 5] }[props.axis ?? "y"];
+ if (!caps) return empty();
+ return Array.from({ length: 6 }, (_, index) =>
+ face(caps.includes(index) ? "oak_log_top" : "oak_log"),
+ );
+ }
+ if (name === "oak_wood") return all("oak_log");
+ if (name === "oak_leaves") return all("oak_leaves", LEAVES, true);
+ if (name === "oak_door") {
+ if ((props.half ?? "lower") !== "lower") return empty();
+ let alongX = ["east", "west"].includes(props.facing ?? "north");
+ if (props.open === "true") alongX = !alongX;
+ const broadFaces = alongX ? [0, 1] : [4, 5];
+ return Array.from({ length: 6 }, (_, index) =>
+ face(broadFaces.includes(index) ? "oak_door_bottom" : "oak_planks"),
+ );
+ }
+ const texture = materials.get(name);
+ return texture ? all(texture, WHITE, texture === "glass") : empty();
+}
+
+/**
+ * UVs address an unflipped bitmap with its first row at V=0. Coordinates stay
+ * relative to the whole block, so slabs and other boxes crop instead of stretch.
+ * Horizontal logs rotate back to the vertical-log frame before projection,
+ * aligning the bark grain with the log axis without mirroring any face.
+ */
+export function getFaceUV(faceIndex, localPos, state = "", textureFace = null) {
+ if (textureFace?.uv) {
+ const uv = textureFace.uv, [x, y, z] = localPos;
+ return [uv[0]*x + uv[1]*y + uv[2]*z + uv[3], uv[4]*x + uv[5]*y + uv[6]*z + uv[7]];
+ }
+ let [x, y, z] = localPos;
+ const log = /^(?:minecraft:)?oak_(?:log|wood)\[([^\]]*)\]$/.exec(state);
+ const axis = log && /(?:^|,)axis=([xz])(?:,|$)/.exec(log[1])?.[1];
+ if (axis === "x") {
+ [x, y, z] = [1 - y, x, z];
+ faceIndex = [2, 3, 1, 0, 4, 5][faceIndex];
+ } else if (axis === "z") {
+ [x, y, z] = [x, z, 1 - y];
+ faceIndex = [0, 1, 5, 4, 2, 3][faceIndex];
+ }
+ switch (faceIndex) {
+ case 0: return [1 - z, 1 - y];
+ case 1: return [z, 1 - y];
+ case 2: return [x, z];
+ case 3: return [x, 1 - z];
+ case 4: return [x, 1 - y];
+ case 5: return [1 - x, 1 - y];
+ default: throw new RangeError(`Unknown block face: ${faceIndex}`);
+ }
+}
diff --git a/client/dynamics-diagnostics.js b/client/dynamics-diagnostics.js
new file mode 100644
index 0000000..c737c6b
--- /dev/null
+++ b/client/dynamics-diagnostics.js
@@ -0,0 +1,60 @@
+const percentile=(values,p)=>{if(!values.length)return 0;const sorted=[...values].sort((a,b)=>a-b);return Math.round(sorted[Math.min(sorted.length-1,Math.floor((sorted.length-1)*p))]*100)/100;};
+const statistics=values=>({p50:percentile(values,.5),p95:percentile(values,.95),p99:percentile(values,.99),max:percentile(values,1)});
+/** Bounded local trace. Durations use monotonic browser time, not wall clocks. */
+export class DynamicsDiagnostics {
+ constructor(){this.status='idle';this.recent=[];this.frames=[];this.events=[];this.server=[];this.result=null;this.scenario='record';this.duration=30;this.toggleSent=false;}
+ arm(scenario='record',duration=30){
+ this.status='warming';this.scenario=scenario;this.duration=Math.min(120,Math.max(5,duration));this.frames=[];this.events=[];this.server=[];this.result=null;this.toggleSent=false;this.climbUntil=0;this.armed=performance.now();this.stableAt=null;
+ }
+ ready(sample,ready,now=performance.now()){
+ if(this.status!=='warming')return;
+ if(ready)this.stableAt??=now;else this.stableAt=null;
+ if(this.stableAt!==null&&now-this.stableAt>=500){this.status='recording';this.started=now;this.initial=sample;this.yaw=sample.yaw;}
+ if(now-this.armed>120000){this.status='error';this.result={error:'Terrain did not settle within 120 seconds',sample};}
+ }
+ frame(sample,now=performance.now()){
+ this.recent.push(sample);if(this.recent.length>300)this.recent.shift();
+ if(this.status!=='recording')return;
+ if(this.frames.length<18000)this.frames.push({...sample,t:now-this.started});
+ if(now-this.started>=this.duration*1000)this.stop(now);
+ }
+ event(type,milliseconds,detail={}){if(this.status==='recording'&&this.events.length<3000)this.events.push({t:performance.now()-this.started,type,milliseconds,...detail});}
+ serverSample(sample){if(this.status==='recording'&&this.server.length<150)this.server.push({t:performance.now()-this.started,...sample});}
+ controls(consume,flying,now=performance.now(),collision=false){
+ if(this.status!=='recording'||this.scenario==='record')return null;
+ const t=(now-this.started)/1000,fly=this.scenario!=='run';
+ let fly_toggle=false;
+ if(fly&&!flying&&!this.toggleSent){fly_toggle=true;if(consume)this.toggleSent=true;}
+ if(fly&&collision&&consume)this.climbUntil=now+900;
+ return {forward:fly&&t<2.5?0:1,strafe:0,jump:fly&&(t<2.5||now<(this.climbUntil||0)),sprint:true,sneak:false,fly_toggle,
+ yaw:this.yaw+(this.scenario==='turns'?Math.sin(t*.8)*1.3:Math.sin(Math.max(0,t-3)*.11)*.35),pitch:fly?-.16:-.08};
+ }
+ stop(now=performance.now(),reason=null){
+ if(this.status==='warming'){this.status='idle';return;}
+ if(this.status!=='recording')return;
+ const f=this.frames,last=f.at(-1)||this.initial,values=k=>f.map(x=>x[k]||0);
+ let distance=0;for(let i=1;iv-f[i-1].position[a]));
+ this.result={schema:1,scenario:this.scenario,interrupted:reason,durationMs:Math.round(now-this.started),frames:f.length,
+ frameMs:statistics(values('frameMs')),physicsMs:statistics(values('physicsMs')),drawMs:statistics(values('drawMs')),
+ over25ms:f.filter(x=>x.frameMs>25).length,over50ms:f.filter(x=>x.frameMs>50).length,
+ streamApplyMs:statistics(this.events.filter(e=>e.type==='sections').map(e=>e.milliseconds)),
+ networkHandlerMs:statistics(this.events.filter(e=>e.type==='network').map(e=>e.milliseconds)),
+ longTasks:this.events.filter(e=>e.type==='longtask').length,
+ maxCorrection:percentile(values('correction'),1),waitingFrames:f.filter(x=>x.waiting).length,
+ maxDirty:percentile(values('dirty'),1),distance:Math.round(distance*100)/100,
+ viewChanges:last.viewChanges-this.initial.viewChanges,sectionBatches:last.batches-this.initial.batches,
+ meshResets:last.meshResets-this.initial.meshResets,loadedSections:last.loadedSections,totalSections:last.totalSections,
+ voxelBytes:last.voxelBytes,server:this.server.at(-1)||null,start:this.initial.position,end:last.position};
+ this.status='done';
+ }
+ trace(){return {summary:this.result,frames:this.frames,events:this.events,server:this.server};}
+}
+export function drawDynamicsChart(canvas,samples){
+ const ctx=canvas.getContext('2d');if(!ctx)return;
+ const w=canvas.width,h=canvas.height;ctx.fillStyle='#10231b';ctx.fillRect(0,0,w,h);
+ ctx.font='11px monospace';ctx.strokeStyle='#405448';ctx.fillStyle='#a5b8ac';
+ for(const ms of [16.7,33.3,50]){const y=h-ms/70*h;ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke();ctx.fillText(`${ms} ms`,4,y-3);}
+ for(const [key,color] of [['frameMs','#efc46a'],['drawMs','#8bd0b4'],['physicsMs','#8bb6ed']]){
+ ctx.strokeStyle=color;ctx.beginPath();samples.forEach((s,i)=>{const x=i/300*w,y=h-Math.min(70,s[key]||0)/70*h;i?ctx.lineTo(x,y):ctx.moveTo(x,y);});ctx.stroke();
+ }
+}
diff --git a/client/edit-diagnostics.js b/client/edit-diagnostics.js
new file mode 100644
index 0000000..aa932a2
--- /dev/null
+++ b/client/edit-diagnostics.js
@@ -0,0 +1,28 @@
+/** Monotonic timings for local requests and authoritative mesh publication. */
+export class EditDiagnostics {
+ constructor() { this.reset(); }
+ reset() { this.requests = new Map(); this.sections = new Map(); this.last = null; }
+ request(pos, block, now = performance.now()) {
+ for (const [key, value] of this.requests) if (now - value.time > 5000) this.requests.delete(key);
+ if (this.requests.size >= 64) this.requests.delete(this.requests.keys().next().value);
+ this.requests.set(pos.join(','), { block, time: now });
+ }
+ confirmed(changes, now = performance.now()) {
+ for (const { pos, block } of changes) {
+ const key = pos.join(','), request = this.requests.get(key);
+ if (!request || request.block !== block || now - request.time > 5000) continue;
+ this.requests.delete(key);
+ const id = pos.map(v => Math.floor(v / 16)).join(',');
+ if (this.sections.size >= 64) this.sections.delete(this.sections.keys().next().value);
+ this.sections.set(id, { request: request.time, confirmed: now });
+ }
+ }
+ published(id, now = performance.now()) {
+ const edit = this.sections.get(id);
+ if (!edit) return null;
+ this.sections.delete(id);
+ this.last = { acknowledgementMs: edit.confirmed - edit.request,
+ geometryMs: now - edit.confirmed, totalMs: now - edit.request };
+ return this.last;
+ }
+}
diff --git a/client/edit-mesh-controller.js b/client/edit-mesh-controller.js
new file mode 100644
index 0000000..17e314c
--- /dev/null
+++ b/client/edit-mesh-controller.js
@@ -0,0 +1,43 @@
+/** Small independent edit jobs cannot queue behind whole-view lighting. */
+export class EditMeshController {
+ constructor({capture,onError=()=>{},workerFactory}={}) {
+ this.capture=capture;this.onError=onError;this.epoch=0;this.job=0;
+ this.pending=new Map();this.tickets=new Map();this.ready=new Map();this.busy=null;
+ try {
+ this.worker=workerFactory ? workerFactory() : new Worker(new URL('./edit-mesh-worker.js',import.meta.url),{type:'module'});
+ this.worker.onmessage=({data})=>this.receive(data);
+ this.worker.onerror=event=>{event.preventDefault();this.fail(event.message);};
+ } catch(error) {this.fail(error.message);}
+ }
+ fail(error) {this.error=error;this.dispose();this.onError(error);}
+ request(ids) {
+ if(this.error||this.disposed)return;
+ for(const id of ids) {
+ const job=++this.job;
+ this.pending.set(id,job);this.tickets.set(id,job);this.ready.delete(id);
+ }
+ }
+ cancel(id) {this.pending.delete(id);this.tickets.delete(id);this.ready.delete(id);}
+ reset() {this.epoch++;this.pending.clear();this.tickets.clear();this.ready.clear();this.busy=null;}
+ isCurrent(result) {return result.epoch===this.epoch&&this.tickets.get(result.id)===result.editJob;}
+ pump() {
+ if(this.error||this.disposed||this.busy||this.ready.size>=2||!this.pending.size)return;
+ const [id,editJob]=this.pending.entries().next().value;
+ this.pending.delete(id);
+ try {
+ const start=performance.now(),packet={...this.capture(id),epoch:this.epoch,editJob};
+ const transfers=packet.sections.map(s=>s.cells.buffer);
+ if(packet.light)transfers.push(packet.light.data.buffer,packet.light.blockLevels.buffer,packet.light.skyLevels.buffer);
+ this.busy={id,editJob,epoch:this.epoch};this.worker.postMessage(packet,transfers);
+ this.prepareMilliseconds=performance.now()-start;
+ } catch(error) {this.fail(error.message);}
+ }
+ receive(result) {
+ if(this.disposed||result.epoch!==this.epoch||result.editJob!==this.busy?.editJob)return;
+ this.busy=null;
+ if(!this.isCurrent(result))return;
+ if(result.error){this.fail(result.error);return;}
+ this.meshMilliseconds=result.milliseconds;this.ready.set(result.id,result);
+ }
+ dispose() {this.disposed=true;this.reset();this.worker?.terminate();}
+}
diff --git a/client/edit-mesh-worker.js b/client/edit-mesh-worker.js
new file mode 100644
index 0000000..8861781
--- /dev/null
+++ b/client/edit-mesh-worker.js
@@ -0,0 +1,10 @@
+import {buildEditMesh} from './edit-mesh.js';
+import {loadLightProperties} from './light-properties.js';
+const properties=loadLightProperties();
+self.onmessage=({data})=>properties.then(table=>{
+ const start=performance.now(),result={id:data.id,epoch:data.epoch,editJob:data.editJob,preview:true};
+ try {
+ const mesh=buildEditMesh(data,table);
+ self.postMessage({...result,...mesh,milliseconds:performance.now()-start},[mesh.vertices.buffer,mesh.transparent.buffer]);
+ } catch(error) {self.postMessage({...result,error:error.message});}
+}).catch(error=>self.postMessage({id:data.id,epoch:data.epoch,editJob:data.editJob,error:error.message}));
diff --git a/client/edit-mesh.js b/client/edit-mesh.js
new file mode 100644
index 0000000..f706056
--- /dev/null
+++ b/client/edit-mesh.js
@@ -0,0 +1,54 @@
+import { SectionVoxelMap } from './section-voxel-map.js';
+import { attachBlockLightSamplers } from './block-light.js';
+import { applyLightProperties } from './light-properties.js';
+import { buildSectionMesh } from './mesh-geometry.js';
+import { textureSubset } from './texture-pack.js';
+
+/** Copy only the edited section's sampling neighborhood, independent of view radius. */
+export function captureEditMesh(id, blocks, materials, textures, field) {
+ const base=id.split(',').map(v=>Number(v)*16);
+ let pad=2;
+ for(const mat of materials.values()) for(const box of mat.render || [])
+ for(let axis=0;axis<3;axis++) pad=Math.max(pad,Math.ceil(-box.min[axis])+2,Math.ceil(box.max[axis]-1)+2);
+ const min=base.map(v=>v-pad),size=Array(3).fill(16+pad*2),sections=[];
+ for(let x=Math.floor(min[0]/16);x<=Math.floor((min[0]+size[0]-1)/16);x++)
+ for(let y=Math.floor(min[1]/16);y<=Math.floor((min[1]+size[1]-1)/16);y++)
+ for(let z=Math.floor(min[2]/16);z<=Math.floor((min[2]+size[2]-1)/16);z++) {
+ const section=`${x},${y},${z}`;
+ if(blocks.getSectionCells) {
+ const cells=blocks.getSectionCells(section);
+ if(cells) sections.push({section,cells:cells.slice()});
+ } else {
+ const cells=new Uint32Array(4096);
+ for(const key of (blocks.keysInSection ? blocks.keysInSection(section) : [...blocks.keys()].filter(key=>key.split(",").map(v=>Math.floor(Number(v)/16)).join(",")===section))) {
+ const p=key.split(',').map(Number);
+ cells[SectionVoxelMap.index(...p)]=blocks.get(key);
+ }
+ sections.push({section,cells});
+ }
+ }
+ let light=null;
+ if(field) {
+ const count=size[0]*size[1]*size[2];
+ light={min,size,data:new Uint8Array(count*4),blockLevels:new Uint8Array(count),skyLevels:new Uint8Array(count)};
+ const lo=min.map((v,i)=>Math.max(v,field.min[i])),hi=min.map((v,i)=>Math.min(v+size[i],field.min[i]+field.size[i]));
+ if(hi[0]>lo[0]) for(let z=lo[2];z used.has(id)));
+ return {id,sections,materials:[...nearbyMaterials],textures:[...textureSubset(textures, nearbyMaterials)],light};
+}
+export function buildEditMesh(packet, properties) {
+ const blocks=new SectionVoxelMap();
+ for(const {section,cells} of packet.sections) blocks.setSection(section,cells);
+ const materials=new Map(packet.materials.map(([id,mat])=>[id,applyLightProperties(mat,properties)]));
+ const field=packet.light ? attachBlockLightSamplers(packet.light,blocks,materials) : null;
+ return buildSectionMesh({id:packet.id,blocks,materials,textureLayers:new Map(packet.textures),blockLightField:field,localCoordinates:true});
+}
diff --git a/client/index.html b/client/index.html
index e4c39a9..4ab483a 100644
--- a/client/index.html
+++ b/client/index.html
@@ -14,7 +14,8 @@
@@ -57,6 +58,7 @@
W A S D движениеПробел прыжокCtrl / Shift бег / присестьЛКМ / ПКМ убрать / поставитьT чат
@@ -91,6 +93,29 @@
побеждает.
+
+
+
+
+
+
+
+
Радиус вокруг игрока. Большая дальность увеличивает нагрузку на память и видеокарту.
+
Esc освобождает мышь. Перетаскивание работает и без захвата. Средняя
- кнопка выбирает блок под прицелом.
+ кнопка выбирает блок под прицелом. Ctrl — бег, Shift — присесть и
+ удержаться у края. Двойной пробел или F включает полёт, если он разрешён
+ в мире; пробел / Shift — вверх / вниз.
@@ -152,6 +179,16 @@
×
+
F3 — скрыть панель. Нажмите на мир, чтобы играть с открытой диагностикой.
+
+
+
+
+
Готов к записи. Автомаршрут использует обычное управление игроком.