pre-alpha 0.0.1 — initial multiplayer voxel sandbox

Web/wasm Rust voxel game with:
- wgpu 23 client (WebGPU when available, WebGL2 fallback)
- Chunked terrain (17x17 chunks, deterministic value-noise generator)
- Greedy meshing with frustum + distance culling
- Sky shader, leaf wind shader, distance fog
- Player physics: substepped AABB collision, gravity, fall damage,
  natural-surface respawn
- Touch UI (MCPE-style joystick + jump/break/place/sprint),
  gamepad polling with axis calibration, mouse+keyboard
- HP / death-screen / respawn flow
- 10-slot hotbar with mouse-wheel + hotkey + tap cycling
- Settings menu (mouse sens, FOV, render distance, input mode toggle)
- Axum multiplayer server: WebSocket protocol, edit log,
  10Hz player broadcasts
- 31 unit tests covering spawn invariants, collision sweeps,
  raycast hit/miss, greedy mesh winding, fall damage, oriented
  box rotation, hotbar block roundtrip, and the input-merge
  regression that latched movement after touch release

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maximus Gorog 2026-05-22 23:33:47 -06:00
commit 3a4ae970b2
20 changed files with 8477 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
# Rust build output
/target
**/target
# Generated wasm bundle — rebuilt by run.sh / build-web.sh
web/voxel_game.js
web/voxel_game_bg.wasm
web/voxel_game.d.ts
# Editor / OS
*.swp
.DS_Store
.idea/
.vscode/
Thumbs.db

2535
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

37
Cargo.toml Normal file
View file

@ -0,0 +1,37 @@
[package]
name = "voxel-game"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
winit = { version = "0.30", features = ["rwh_06"] }
wgpu = "23"
glam = { version = "0.29", features = ["bytemuck"] }
bytemuck = { version = "1", features = ["derive"] }
pollster = "0.4"
log = "0.4"
env_logger = "0.11"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1"
console_log = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"Document", "Window", "Element", "HtmlCanvasElement", "Location", "Performance", "Navigator",
] }
wgpu = { version = "23", features = ["webgl"] }
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
[profile.dev]
opt-level = 1

31
build-web.sh Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Build the wasm bundle and copy it into ./web so you can serve that directory.
#
# Requires: wasm-bindgen-cli installed (`cargo install wasm-bindgen-cli`)
# and the wasm32 target (`rustup target add wasm32-unknown-unknown`).
set -euo pipefail
cd "$(dirname "$0")"
MODE="${1:-release}"
case "$MODE" in
release)
cargo build --target wasm32-unknown-unknown --release --lib
WASM="target/wasm32-unknown-unknown/release/voxel_game.wasm"
;;
debug)
cargo build --target wasm32-unknown-unknown --lib
WASM="target/wasm32-unknown-unknown/debug/voxel_game.wasm"
;;
*)
echo "usage: $0 [release|debug]" >&2
exit 1
;;
esac
wasm-bindgen --target web --out-dir web --no-typescript "$WASM"
echo
echo "Build complete. Serve the web/ directory, e.g.:"
echo " python3 -m http.server --directory web 8080"
echo "Then open http://localhost:8080/"

96
run.sh Executable file
View file

@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Build the wasm bundle, build + run the multiplayer server, and open a public
# tunnel via localhost.run (free, ssh-based, no signup).
#
# Usage:
# ./run.sh # build everything then run server + tunnel
# ./run.sh --no-tunnel # local only (http://localhost:8080)
# ./run.sh --no-build # skip rebuild; just run
set -euo pipefail
cd "$(dirname "$0")"
DO_BUILD=1
DO_TUNNEL=1
for arg in "$@"; do
case "$arg" in
--no-build) DO_BUILD=0 ;;
--no-tunnel) DO_TUNNEL=0 ;;
*) echo "unknown arg: $arg" >&2; exit 1 ;;
esac
done
if [[ $DO_BUILD -eq 1 ]]; then
echo "==> running unit tests (proves spawn / collision / mesh invariants)"
cargo test --lib
echo "==> building wasm client (release)"
cargo build --target wasm32-unknown-unknown --release --lib
~/.cargo/bin/wasm-bindgen --target web --out-dir web --no-typescript \
target/wasm32-unknown-unknown/release/voxel_game.wasm
echo "==> building server (release)"
(cd server && cargo build --release)
# Catch JS no-undef / parse errors before they hit the browser. eslint
# is optional: warns if absent so this step never blocks an emergency
# deploy, but normally you should `npm i -g eslint` to get it.
if command -v npx >/dev/null 2>&1; then
if [[ -f web/.eslintrc.json ]]; then
echo "==> linting web/main.js"
(cd web && npx --no-install eslint main.js) \
|| echo " (eslint reported issues; not fatal)"
fi
else
echo " (skipping JS lint: npx not installed)"
fi
fi
SERVER_BIN="./target/release/voxel-server"
if [[ ! -x "$SERVER_BIN" ]]; then
# workspace fallback: server has its own target
SERVER_BIN="./server/target/release/voxel-server"
fi
# Kill any previous instance.
pkill -f 'voxel-server' 2>/dev/null || true
pkill -f 'http.server --directory web' 2>/dev/null || true
sleep 0.3
echo "==> starting server on :8080"
STATIC_DIR="$(pwd)/web" "$SERVER_BIN" &
SERVER_PID=$!
trap 'echo; echo "stopping..."; kill $SERVER_PID 2>/dev/null || true; kill $TUNNEL_PID 2>/dev/null || true; exit' INT TERM
# Wait for server to come up.
for i in {1..20}; do
if curl -sf -o /dev/null http://localhost:8080/; then break; fi
sleep 0.2
done
if [[ $DO_TUNNEL -eq 0 ]]; then
echo "==> server running at http://localhost:8080 (no tunnel)"
wait $SERVER_PID
exit
fi
echo "==> opening Cloudflare quick tunnel (free, no signup)"
echo " look for the trycloudflare.com URL below; share that link."
echo
CLOUDFLARED="${CLOUDFLARED:-$HOME/.local/bin/cloudflared}"
if [[ ! -x "$CLOUDFLARED" ]] && command -v cloudflared >/dev/null 2>&1; then
CLOUDFLARED="$(command -v cloudflared)"
fi
if [[ ! -x "$CLOUDFLARED" ]]; then
echo "cloudflared not found. Install it with:"
echo " mkdir -p ~/.local/bin && curl -sL -o ~/.local/bin/cloudflared \\"
echo " https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \\"
echo " && chmod +x ~/.local/bin/cloudflared"
kill $SERVER_PID 2>/dev/null || true
exit 1
fi
"$CLOUDFLARED" tunnel --url http://localhost:8080 --no-autoupdate &
TUNNEL_PID=$!
wait $SERVER_PID
kill $TUNNEL_PID 2>/dev/null || true

900
server/Cargo.lock generated Normal file
View file

@ -0,0 +1,900 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"base64",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voxel-server"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"serde",
"serde_json",
"tokio",
"tower-http",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

20
server/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "voxel-server"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "voxel-server"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["fs"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures-util = "0.3"
[profile.release]
opt-level = 3
lto = "thin"

208
server/src/main.rs Normal file
View file

@ -0,0 +1,208 @@
mod proto;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
routing::any,
Router,
};
use futures_util::{SinkExt, StreamExt};
use proto::{ClientMsg, EditRec, PlayerInfo, ServerMsg};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
#[derive(Clone)]
struct AppState {
next_id: Arc<RwLock<u32>>,
players: Arc<RwLock<HashMap<u32, PlayerState>>>,
edits: Arc<RwLock<Vec<EditRec>>>,
tx: broadcast::Sender<ServerMsg>,
}
#[derive(Clone, Debug)]
struct PlayerState {
name: String,
x: f32,
y: f32,
z: f32,
yaw: f32,
pitch: f32,
}
#[tokio::main]
async fn main() {
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8080);
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../web".to_string());
let (tx, _) = broadcast::channel::<ServerMsg>(512);
let state = AppState {
next_id: Arc::new(RwLock::new(1)),
players: Arc::new(RwLock::new(HashMap::new())),
edits: Arc::new(RwLock::new(Vec::new())),
tx: tx.clone(),
};
// 10 Hz player-list broadcaster.
{
let s = state.clone();
tokio::spawn(async move {
let mut iv = tokio::time::interval(std::time::Duration::from_millis(100));
loop {
iv.tick().await;
let players = s.players.read().await;
if players.is_empty() {
continue;
}
let list: Vec<PlayerInfo> = players
.iter()
.map(|(id, p)| PlayerInfo {
id: *id,
name: p.name.clone(),
x: p.x,
y: p.y,
z: p.z,
yaw: p.yaw,
pitch: p.pitch,
})
.collect();
drop(players);
let _ = s.tx.send(ServerMsg::Players { list });
}
});
}
let app = Router::new()
.route("/ws", any(ws_handler))
.fallback_service(tower_http::services::ServeDir::new(&static_dir))
.with_state(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
println!("voxel-server listening on http://0.0.0.0:{port}");
println!("serving static files from {static_dir}");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: AppState) {
let id = {
let mut n = state.next_id.write().await;
let id = *n;
*n += 1;
id
};
println!("client {id} connected");
let mut rx = state.tx.subscribe();
let (mut sender, mut receiver) = socket.split();
// Send Welcome.
{
let edits = state.edits.read().await.clone();
let welcome = ServerMsg::Welcome { id, edits };
if let Ok(s) = serde_json::to_string(&welcome) {
if sender.send(Message::Text(s)).await.is_err() {
return;
}
}
}
// Register an empty placeholder; will be populated by Hello/State.
{
let mut players = state.players.write().await;
players.insert(
id,
PlayerState {
name: format!("guest-{id}"),
x: 0.0,
y: 60.0,
z: 0.0,
yaw: 0.0,
pitch: 0.0,
},
);
}
let state_out = state.clone();
let send_task = tokio::spawn(async move {
while let Ok(msg) = rx.recv().await {
let text = match serde_json::to_string(&msg) {
Ok(t) => t,
Err(_) => continue,
};
if sender.send(Message::Text(text)).await.is_err() {
break;
}
}
});
while let Some(Ok(msg)) = receiver.next().await {
match msg {
Message::Text(t) => {
let Ok(cm) = serde_json::from_str::<ClientMsg>(&t) else {
continue;
};
match cm {
ClientMsg::Hello { name } => {
if let Some(p) = state_out.players.write().await.get_mut(&id) {
// sanitize name
let safe: String = name
.chars()
.filter(|c| !c.is_control())
.take(24)
.collect();
p.name = if safe.is_empty() {
format!("guest-{id}")
} else {
safe
};
}
}
ClientMsg::State { x, y, z, yaw, pitch } => {
if let Some(p) = state_out.players.write().await.get_mut(&id) {
p.x = x;
p.y = y;
p.z = z;
p.yaw = yaw;
p.pitch = pitch;
}
}
ClientMsg::Edit { x, y, z, block } => {
let rec = EditRec { x, y, z, block };
// Replace any prior edit at the same coord to avoid log bloat.
{
let mut edits = state_out.edits.write().await;
edits.retain(|e| !(e.x == x && e.y == y && e.z == z));
edits.push(rec.clone());
}
let _ = state_out.tx.send(ServerMsg::Edit {
x: rec.x,
y: rec.y,
z: rec.z,
block: rec.block,
});
}
}
}
Message::Close(_) => break,
_ => {}
}
}
send_task.abort();
state.players.write().await.remove(&id);
let _ = state.tx.send(ServerMsg::Leave { id });
println!("client {id} disconnected");
}

37
server/src/proto.rs Normal file
View file

@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "t")]
pub enum ClientMsg {
Hello { name: String },
State { x: f32, y: f32, z: f32, yaw: f32, pitch: f32 },
Edit { x: i32, y: i32, z: i32, block: u8 },
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "t")]
pub enum ServerMsg {
Welcome { id: u32, edits: Vec<EditRec> },
Players { list: Vec<PlayerInfo> },
Edit { x: i32, y: i32, z: i32, block: u8 },
Leave { id: u32 },
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PlayerInfo {
pub id: u32,
pub name: String,
pub x: f32,
pub y: f32,
pub z: f32,
pub yaw: f32,
pub pitch: f32,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct EditRec {
pub x: i32,
pub y: i32,
pub z: i32,
pub block: u8,
}

81
src/camera.rs Normal file
View file

@ -0,0 +1,81 @@
use glam::{Mat4, Vec3};
pub struct Camera {
pub position: Vec3,
pub yaw: f32,
pub pitch: f32,
pub aspect: f32,
pub fovy: f32,
pub near: f32,
pub far: f32,
}
impl Camera {
pub fn new(aspect: f32) -> Self {
Self {
position: Vec3::new(0.0, 32.0, 0.0),
yaw: -std::f32::consts::FRAC_PI_2,
pitch: -0.3,
aspect,
fovy: 70f32.to_radians(),
near: 0.05,
far: 800.0,
}
}
pub fn forward(&self) -> Vec3 {
let cp = self.pitch.cos();
Vec3::new(self.yaw.cos() * cp, self.pitch.sin(), self.yaw.sin() * cp).normalize()
}
pub fn forward_flat(&self) -> Vec3 {
Vec3::new(self.yaw.cos(), 0.0, self.yaw.sin()).normalize_or_zero()
}
pub fn right_flat(&self) -> Vec3 {
let f = self.forward_flat();
Vec3::new(-f.z, 0.0, f.x)
}
pub fn view_proj(&self) -> Mat4 {
let view = Mat4::look_to_rh(self.position, self.forward(), Vec3::Y);
let proj = Mat4::perspective_rh(self.fovy, self.aspect, self.near, self.far);
proj * view
}
}
/// Sticky keyboard hold state. The KbInput handler writes `true` on KeyDown
/// and `false` on KeyUp, so the field always reflects whether the key is
/// *currently* held. Merged with `TouchBridge` each tick into a local — never
/// folded back into a persistent field, which was the source of the
/// "joystick release leaves the player walking" bug.
#[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct KbHeld {
pub forward: bool,
pub back: bool,
pub left: bool,
pub right: bool,
pub up: bool,
pub down: bool,
pub sprint: bool,
}
/// Per-tick input that *isn't* sticky directional state: pending mouse
/// motion to consume, click one-shots, and the currently-selected block.
#[derive(Default)]
pub struct InputState {
pub mouse_dx: f32,
pub mouse_dy: f32,
pub primary_clicked: bool,
pub secondary_clicked: bool,
pub selected_block: u8,
}
impl InputState {
pub fn consume_mouse(&mut self) -> (f32, f32) {
let r = (self.mouse_dx, self.mouse_dy);
self.mouse_dx = 0.0;
self.mouse_dy = 0.0;
r
}
}

38
src/lib.rs Normal file
View file

@ -0,0 +1,38 @@
pub mod camera;
pub mod mesh;
pub mod proto;
pub mod state;
pub mod world;
use winit::event_loop::EventLoop;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(start))]
pub fn run() {
#[cfg(target_arch = "wasm32")]
{
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
console_log::init_with_level(log::Level::Info).ok();
}
#[cfg(not(target_arch = "wasm32"))]
{
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
}
let event_loop = EventLoop::new().unwrap();
event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
#[allow(unused_mut)]
let mut app = state::App::default();
#[cfg(not(target_arch = "wasm32"))]
{
event_loop.run_app(&mut app).unwrap();
}
#[cfg(target_arch = "wasm32")]
{
use winit::platform::web::EventLoopExtWebSys;
event_loop.spawn_app(app);
}
}

3
src/main.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
voxel_game::run();
}

275
src/mesh.rs Normal file
View file

@ -0,0 +1,275 @@
use crate::world::{Block, Chunk, Face, World, CHUNK_HEIGHT, CHUNK_SIZE};
use bytemuck::{Pod, Zeroable};
use glam::IVec3;
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable, Debug)]
pub struct Vertex {
pub pos: [f32; 3],
pub color: [f32; 3],
pub normal: [f32; 3],
pub leaf: f32,
}
impl Vertex {
pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Self>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![
0 => Float32x3,
1 => Float32x3,
2 => Float32x3,
3 => Float32,
],
};
}
pub struct ChunkMesh {
pub vertices: Vec<Vertex>,
pub indices: Vec<u32>,
}
/// Greedy meshing: per face direction, build a 2D mask per slice and merge same-block
/// rectangles into one quad. Dramatically reduces triangle count on large flat regions
/// (terrain, big walls).
pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
let mut vertices: Vec<Vertex> = Vec::with_capacity(2048);
let mut indices: Vec<u32> = Vec::with_capacity(3072);
let base_x = chunk.coord.x * CHUNK_SIZE;
let base_z = chunk.coord.z * CHUNK_SIZE;
let dims = [CHUNK_SIZE, CHUNK_HEIGHT, CHUNK_SIZE];
for face in Face::ALL {
let normal = face.normal();
let positive = matches!(face, Face::PosX | Face::PosY | Face::PosZ);
let axis: usize = match face {
Face::PosX | Face::NegX => 0,
Face::PosY | Face::NegY => 1,
Face::PosZ | Face::NegZ => 2,
};
let u_axis = (axis + 1) % 3;
let v_axis = (axis + 2) % 3;
let size_a = dims[axis];
let size_u = dims[u_axis];
let size_v = dims[v_axis];
let n_arr = [normal.x as f32, normal.y as f32, normal.z as f32];
let mut mask: Vec<Option<Block>> = vec![None; (size_u * size_v) as usize];
for d in 0..size_a {
for cell in mask.iter_mut() {
*cell = None;
}
for v in 0..size_v {
for u in 0..size_u {
let mut p = [0i32; 3];
p[axis] = d;
p[u_axis] = u;
p[v_axis] = v;
let block = chunk.get(p[0], p[1], p[2]);
if !block.solid() {
continue;
}
let nx = p[0] + normal.x;
let ny = p[1] + normal.y;
let nz = p[2] + normal.z;
let neighbor_solid = if ny < 0 || ny >= CHUNK_HEIGHT {
false
} else if nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE {
chunk.get(nx, ny, nz).solid()
} else {
world
.get_block(IVec3::new(base_x + nx, ny, base_z + nz))
.solid()
};
if !neighbor_solid {
mask[(v * size_u + u) as usize] = Some(block);
}
}
}
for v0 in 0..size_v {
let mut u0 = 0;
while u0 < size_u {
let head = mask[(v0 * size_u + u0) as usize];
if let Some(b) = head {
let mut w = 1;
while u0 + w < size_u
&& mask[(v0 * size_u + u0 + w) as usize] == Some(b)
{
w += 1;
}
let mut h = 1;
'row: while v0 + h < size_v {
for k in 0..w {
if mask[((v0 + h) * size_u + u0 + k) as usize] != Some(b) {
break 'row;
}
}
h += 1;
}
let slice = if positive { d + 1 } else { d };
let to_world = |u_val: i32, v_val: i32| -> [f32; 3] {
let mut p = [0f32; 3];
p[axis] = slice as f32;
p[u_axis] = u_val as f32;
p[v_axis] = v_val as f32;
[p[0] + base_x as f32, p[1], p[2] + base_z as f32]
};
let c0 = to_world(u0, v0);
let c1 = to_world(u0 + w, v0);
let c2 = to_world(u0 + w, v0 + h);
let c3 = to_world(u0, v0 + h);
let color = b.face_color(face);
let leaf = if b == Block::Leaves { 1.0 } else { 0.0 };
let base_idx = vertices.len() as u32;
for c in [c0, c1, c2, c3] {
vertices.push(Vertex {
pos: c,
color,
normal: n_arr,
leaf,
});
}
if positive {
indices.extend_from_slice(&[
base_idx,
base_idx + 1,
base_idx + 2,
base_idx,
base_idx + 2,
base_idx + 3,
]);
} else {
indices.extend_from_slice(&[
base_idx,
base_idx + 2,
base_idx + 1,
base_idx,
base_idx + 3,
base_idx + 2,
]);
}
for hh in 0..h {
for ww in 0..w {
mask[((v0 + hh) * size_u + u0 + ww) as usize] = None;
}
}
u0 += w;
} else {
u0 += 1;
}
}
}
}
}
ChunkMesh { vertices, indices }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::world::{Block, Chunk, World, CHUNK_HEIGHT, CHUNK_SIZE};
/// A world containing exactly one chunk at the origin with all blocks
/// you put into it via the closure, and nothing else.
fn single_chunk_world(fill: impl FnOnce(&mut Chunk)) -> World {
let mut world = World {
chunks: std::collections::HashMap::new(),
};
let mut chunk = Chunk::new(IVec3::ZERO);
fill(&mut chunk);
world.chunks.insert(IVec3::ZERO, chunk);
world
}
fn cross_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
// Cross product of (b - a) x (c - a)
let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
[
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
]
}
#[test]
fn single_block_produces_six_quads() {
let world = single_chunk_world(|c| c.set(8, 4, 8, Block::Stone));
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
let mesh = build_chunk_mesh(&world, chunk);
assert_eq!(mesh.vertices.len(), 6 * 4, "6 faces × 4 verts");
assert_eq!(mesh.indices.len(), 6 * 6, "6 faces × 2 triangles × 3 indices");
}
#[test]
fn winding_is_ccw_with_outward_normal() {
// For every triangle the cross product of its first two edges must
// point the same way as the stored vertex normal. This catches the
// back-face culling bug we already shipped once.
let world = single_chunk_world(|c| c.set(8, 4, 8, Block::Stone));
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
let mesh = build_chunk_mesh(&world, chunk);
for tri in mesh.indices.chunks_exact(3) {
let a = mesh.vertices[tri[0] as usize].pos;
let b = mesh.vertices[tri[1] as usize].pos;
let c = mesh.vertices[tri[2] as usize].pos;
let n = mesh.vertices[tri[0] as usize].normal;
let geo = cross_normal(a, b, c);
let dot = geo[0] * n[0] + geo[1] * n[1] + geo[2] * n[2];
assert!(
dot > 0.0,
"triangle [{},{},{}] winds opposite its stored normal {:?} (cross={:?})",
tri[0], tri[1], tri[2], n, geo
);
}
}
#[test]
fn fully_solid_interior_emits_no_internal_faces() {
// Fill a 3×3×3 block of solids in the middle of the chunk. Only the
// outer faces of the cube should appear in the mesh; interior shared
// faces must cull each other.
let world = single_chunk_world(|c| {
for x in 6..9 {
for y in 4..7 {
for z in 6..9 {
c.set(x, y, z, Block::Stone);
}
}
}
});
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
let mesh = build_chunk_mesh(&world, chunk);
// 3×3 = 9 quads per outward face × 6 faces = 54 quads at most.
// With greedy meshing these merge to one big quad per side: 6 quads.
assert!(
mesh.vertices.len() <= 6 * 4,
"greedy meshing should merge a 3x3x3 cube into 6 single quads, got {} verts",
mesh.vertices.len()
);
}
#[test]
fn empty_chunk_produces_no_geometry() {
let world = single_chunk_world(|_| {});
let chunk = world.chunks.get(&IVec3::ZERO).unwrap();
let mesh = build_chunk_mesh(&world, chunk);
assert!(mesh.vertices.is_empty());
assert!(mesh.indices.is_empty());
}
// Touch the consts so the test compiles cleanly even if everything else
// is being rearranged.
#[test]
fn world_constants_are_sane() {
assert!(CHUNK_SIZE > 0);
assert!(CHUNK_HEIGHT > 0);
}
}

37
src/proto.rs Normal file
View file

@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "t")]
pub enum ClientMsg {
Hello { name: String },
State { x: f32, y: f32, z: f32, yaw: f32, pitch: f32 },
Edit { x: i32, y: i32, z: i32, block: u8 },
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "t")]
pub enum ServerMsg {
Welcome { id: u32, edits: Vec<EditRec> },
Players { list: Vec<PlayerInfo> },
Edit { x: i32, y: i32, z: i32, block: u8 },
Leave { id: u32 },
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PlayerInfo {
pub id: u32,
pub name: String,
pub x: f32,
pub y: f32,
pub z: f32,
pub yaw: f32,
pub pitch: f32,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct EditRec {
pub x: i32,
pub y: i32,
pub z: i32,
pub block: u8,
}

128
src/shader.wgsl Normal file
View file

@ -0,0 +1,128 @@
struct Camera {
view_proj: mat4x4<f32>,
inv_view_proj: mat4x4<f32>,
eye: vec4<f32>,
misc: vec4<f32>,
};
@group(0) @binding(0) var<uniform> camera: Camera;
const SUN_DIR: vec3<f32> = vec3<f32>(0.42, 0.82, 0.39);
const SKY_HORIZON: vec3<f32> = vec3<f32>(0.78, 0.88, 0.96);
const SKY_ZENITH: vec3<f32> = vec3<f32>(0.30, 0.55, 0.88);
const SUN_COLOR: vec3<f32> = vec3<f32>(1.0, 0.95, 0.85);
fn sky_color(dir: vec3<f32>) -> vec3<f32> {
let up = clamp(dir.y, -1.0, 1.0);
let t = pow(max(up, 0.0), 0.55);
let base = mix(SKY_HORIZON, SKY_ZENITH, t);
// Slight darken below horizon (mostly never seen, but soft).
let below = step(up, 0.0) * 0.2;
let s = max(dot(normalize(dir), SUN_DIR), 0.0);
let disc = pow(s, 800.0) * 1.4;
let halo = pow(s, 6.0) * 0.18;
return base * (1.0 - below) + SUN_COLOR * (disc + halo);
}
struct VsIn {
@location(0) pos: vec3<f32>,
@location(1) color: vec3<f32>,
@location(2) normal: vec3<f32>,
@location(3) leaf: f32,
};
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) color: vec3<f32>,
@location(2) normal: vec3<f32>,
@location(3) leaf: f32,
};
@vertex
fn vs_main(in: VsIn) -> VsOut {
var pos = in.pos;
if (in.leaf > 0.5) {
let t = camera.misc.x;
let phase = pos.x * 0.35 + pos.z * 0.27 + pos.y * 0.11;
let sway = sin(t * 1.6 + phase) * 0.045;
let sway2 = cos(t * 1.1 + phase * 1.3) * 0.035;
pos.x = pos.x + sway;
pos.z = pos.z + sway2;
pos.y = pos.y + sway * 0.25;
}
var out: VsOut;
out.clip = camera.view_proj * vec4<f32>(pos, 1.0);
out.world_pos = pos;
out.color = in.color;
out.normal = in.normal;
out.leaf = in.leaf;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let n = normalize(in.normal);
let ndl = max(dot(n, SUN_DIR), 0.0);
let ambient = 0.40;
var lit = in.color * (ambient + (1.0 - ambient) * ndl);
// Cheap procedural noise for leaves so the canopy doesn't look uniform.
if (in.leaf > 0.5) {
let n2 = fract(sin(dot(floor(in.world_pos * 1.3), vec3<f32>(12.9898, 78.233, 37.719))) * 43758.5453);
lit = lit * (0.88 + n2 * 0.18);
}
let to_eye = camera.eye.xyz - in.world_pos;
let dist = length(to_eye);
let view_dir = -to_eye / max(dist, 0.0001);
let sky = sky_color(-view_dir);
let fog_start = 90.0;
let fog_end = 320.0;
let fog_t = clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0);
let color = mix(lit, sky, fog_t);
return vec4<f32>(color, 1.0);
}
// ---- Sky background (full-screen triangle) ----
struct SkyOut {
@builtin(position) clip: vec4<f32>,
@location(0) ndc: vec2<f32>,
};
@vertex
fn vs_sky(@builtin(vertex_index) idx: u32) -> SkyOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let p = corners[idx];
var out: SkyOut;
out.clip = vec4<f32>(p, 1.0, 1.0);
out.ndc = p;
return out;
}
@fragment
fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
let far_h = camera.inv_view_proj * vec4<f32>(in.ndc.x, in.ndc.y, 1.0, 1.0);
let world_pos = far_h.xyz / far_h.w;
let dir = normalize(world_pos - camera.eye.xyz);
return vec4<f32>(sky_color(dir), 1.0);
}
// ---- Outline ----
@vertex
fn vs_outline(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
return camera.view_proj * vec4<f32>(pos, 1.0);
}
@fragment
fn fs_outline() -> @location(0) vec4<f32> {
return vec4<f32>(0.05, 0.05, 0.07, 1.0);
}

2314
src/state.rs Normal file

File diff suppressed because it is too large Load diff

410
src/world.rs Normal file
View file

@ -0,0 +1,410 @@
use glam::{IVec3, Vec3};
use std::collections::HashMap;
pub const CHUNK_SIZE: i32 = 16;
pub const CHUNK_HEIGHT: i32 = 64;
pub const WORLD_RADIUS: i32 = 8;
#[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Block {
Air = 0,
Grass = 1,
Dirt = 2,
Stone = 3,
Sand = 4,
Wood = 5,
Leaves = 6,
Cobble = 7,
Brick = 8,
Snow = 9,
Ice = 10,
}
impl Block {
pub fn solid(self) -> bool {
!matches!(self, Block::Air)
}
pub fn face_color(self, face: Face) -> [f32; 3] {
match (self, face) {
(Block::Grass, Face::PosY) => [0.36, 0.74, 0.32],
(Block::Grass, Face::NegY) => [0.55, 0.36, 0.20],
(Block::Grass, _) => [0.45, 0.55, 0.25],
(Block::Dirt, _) => [0.55, 0.36, 0.20],
(Block::Stone, _) => [0.50, 0.50, 0.52],
(Block::Sand, _) => [0.88, 0.82, 0.55],
(Block::Wood, Face::PosY | Face::NegY) => [0.62, 0.46, 0.28],
(Block::Wood, _) => [0.42, 0.30, 0.18],
(Block::Leaves, _) => [0.20, 0.55, 0.22],
(Block::Cobble, _) => [0.42, 0.42, 0.45],
(Block::Brick, _) => [0.65, 0.30, 0.22],
(Block::Snow, _) => [0.95, 0.96, 0.98],
(Block::Ice, _) => [0.62, 0.82, 0.95],
(Block::Air, _) => [0.0, 0.0, 0.0],
}
}
}
#[derive(Copy, Clone, Debug)]
pub enum Face {
PosX,
NegX,
PosY,
NegY,
PosZ,
NegZ,
}
impl Face {
pub const ALL: [Face; 6] = [
Face::PosX,
Face::NegX,
Face::PosY,
Face::NegY,
Face::PosZ,
Face::NegZ,
];
pub fn normal(self) -> IVec3 {
match self {
Face::PosX => IVec3::X,
Face::NegX => IVec3::NEG_X,
Face::PosY => IVec3::Y,
Face::NegY => IVec3::NEG_Y,
Face::PosZ => IVec3::Z,
Face::NegZ => IVec3::NEG_Z,
}
}
pub fn corners(self) -> [[f32; 3]; 4] {
match self {
Face::PosX => [[1.0, 0.0, 0.0], [1.0, 0.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 0.0]],
Face::NegX => [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 1.0, 1.0]],
Face::PosY => [[0.0, 1.0, 0.0], [1.0, 1.0, 0.0], [1.0, 1.0, 1.0], [0.0, 1.0, 1.0]],
Face::NegY => [[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0]],
Face::PosZ => [[1.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 1.0, 1.0], [1.0, 1.0, 1.0]],
Face::NegZ => [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
}
}
}
#[derive(Clone)]
pub struct Chunk {
pub blocks: Vec<Block>,
pub coord: IVec3,
pub dirty: bool,
}
impl Chunk {
pub fn new(coord: IVec3) -> Self {
Self {
blocks: vec![Block::Air; (CHUNK_SIZE * CHUNK_HEIGHT * CHUNK_SIZE) as usize],
coord,
dirty: true,
}
}
#[inline]
pub fn index(x: i32, y: i32, z: i32) -> usize {
((y * CHUNK_SIZE + z) * CHUNK_SIZE + x) as usize
}
pub fn get(&self, x: i32, y: i32, z: i32) -> Block {
if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE {
return Block::Air;
}
self.blocks[Self::index(x, y, z)]
}
pub fn set(&mut self, x: i32, y: i32, z: i32, b: Block) {
if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE {
return;
}
self.blocks[Self::index(x, y, z)] = b;
self.dirty = true;
}
}
pub struct World {
pub chunks: HashMap<IVec3, Chunk>,
}
impl World {
pub fn new() -> Self {
let mut chunks = HashMap::new();
for cx in -WORLD_RADIUS..=WORLD_RADIUS {
for cz in -WORLD_RADIUS..=WORLD_RADIUS {
let coord = IVec3::new(cx, 0, cz);
let chunk = generate_chunk(coord);
chunks.insert(coord, chunk);
}
}
Self { chunks }
}
pub fn block_to_chunk(pos: IVec3) -> (IVec3, IVec3) {
let cx = pos.x.div_euclid(CHUNK_SIZE);
let cz = pos.z.div_euclid(CHUNK_SIZE);
let lx = pos.x.rem_euclid(CHUNK_SIZE);
let lz = pos.z.rem_euclid(CHUNK_SIZE);
(IVec3::new(cx, 0, cz), IVec3::new(lx, pos.y, lz))
}
pub fn get_block(&self, pos: IVec3) -> Block {
if pos.y < 0 || pos.y >= CHUNK_HEIGHT {
return Block::Air;
}
let (c, l) = Self::block_to_chunk(pos);
match self.chunks.get(&c) {
Some(chunk) => chunk.get(l.x, l.y, l.z),
None => Block::Air,
}
}
pub fn set_block(&mut self, pos: IVec3, b: Block) -> bool {
if pos.y < 0 || pos.y >= CHUNK_HEIGHT {
return false;
}
let (c, l) = Self::block_to_chunk(pos);
let Some(chunk) = self.chunks.get_mut(&c) else {
return false;
};
chunk.set(l.x, l.y, l.z, b);
// Mark neighbors dirty too so face culling is correct.
for face in Face::ALL {
let n = pos + face.normal();
let (nc, _) = Self::block_to_chunk(n);
if nc != c {
if let Some(neighbor) = self.chunks.get_mut(&nc) {
neighbor.dirty = true;
}
}
}
true
}
/// Voxel DDA raycast. Returns (hit_block_pos, previous_pos) of the first solid block.
pub fn raycast(&self, origin: Vec3, dir: Vec3, max_dist: f32) -> Option<(IVec3, IVec3)> {
let dir = dir.normalize_or_zero();
if dir.length_squared() == 0.0 {
return None;
}
let mut pos = IVec3::new(
origin.x.floor() as i32,
origin.y.floor() as i32,
origin.z.floor() as i32,
);
let step = IVec3::new(
dir.x.signum() as i32,
dir.y.signum() as i32,
dir.z.signum() as i32,
);
let next_boundary = |o: f32, d: f32, p: i32| -> f32 {
if d > 0.0 {
(p as f32 + 1.0 - o) / d
} else if d < 0.0 {
(p as f32 - o) / d
} else {
f32::INFINITY
}
};
let mut t_max = Vec3::new(
next_boundary(origin.x, dir.x, pos.x),
next_boundary(origin.y, dir.y, pos.y),
next_boundary(origin.z, dir.z, pos.z),
);
let t_delta = Vec3::new(
(1.0 / dir.x).abs(),
(1.0 / dir.y).abs(),
(1.0 / dir.z).abs(),
);
let mut prev = pos;
for _ in 0..256 {
if self.get_block(pos).solid() {
return Some((pos, prev));
}
prev = pos;
let t;
if t_max.x < t_max.y && t_max.x < t_max.z {
t = t_max.x;
pos.x += step.x;
t_max.x += t_delta.x;
} else if t_max.y < t_max.z {
t = t_max.y;
pos.y += step.y;
t_max.y += t_delta.y;
} else {
t = t_max.z;
pos.z += step.z;
t_max.z += t_delta.z;
}
if t > max_dist {
return None;
}
}
None
}
}
fn hash2(x: i32, z: i32) -> f32 {
let mut h = (x as u32).wrapping_mul(374761393)
^ (z as u32).wrapping_mul(668265263);
h = (h ^ (h >> 13)).wrapping_mul(1274126177);
h ^= h >> 16;
(h as f32 / u32::MAX as f32) * 2.0 - 1.0
}
fn smooth(t: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
fn value_noise(x: f32, z: f32) -> f32 {
let xi = x.floor() as i32;
let zi = z.floor() as i32;
let xf = x - xi as f32;
let zf = z - zi as f32;
let v00 = hash2(xi, zi);
let v10 = hash2(xi + 1, zi);
let v01 = hash2(xi, zi + 1);
let v11 = hash2(xi + 1, zi + 1);
let u = smooth(xf);
let v = smooth(zf);
let a = v00 + (v10 - v00) * u;
let b = v01 + (v11 - v01) * u;
a + (b - a) * v
}
/// The y of the topmost natural-terrain block (i.e. ignoring any
/// player-placed edits) at world column `(x, z)`. The block *at* this y
/// is solid; `y + 1` is the first air block above the natural surface.
pub fn natural_surface_y(x: i32, z: i32) -> i32 {
let wx = x as f32;
let wz = z as f32;
let n = fbm(wx * 0.04, wz * 0.04);
let height = (20.0 + n * 12.0).round() as i32;
height.clamp(1, CHUNK_HEIGHT - 1) - 1
}
fn fbm(x: f32, z: f32) -> f32 {
let mut amp = 1.0;
let mut freq = 1.0;
let mut sum = 0.0;
let mut norm = 0.0;
for _ in 0..4 {
sum += value_noise(x * freq, z * freq) * amp;
norm += amp;
amp *= 0.5;
freq *= 2.0;
}
sum / norm
}
fn generate_chunk(coord: IVec3) -> Chunk {
let mut chunk = Chunk::new(coord);
let ox = coord.x * CHUNK_SIZE;
let oz = coord.z * CHUNK_SIZE;
for x in 0..CHUNK_SIZE {
for z in 0..CHUNK_SIZE {
let wx = (ox + x) as f32;
let wz = (oz + z) as f32;
let n = fbm(wx * 0.04, wz * 0.04);
let height = (20.0 + n * 12.0).round() as i32;
let height = height.clamp(1, CHUNK_HEIGHT - 1);
for y in 0..height {
let b = if y == height - 1 {
if height < 18 {
Block::Sand
} else {
Block::Grass
}
} else if y > height - 4 {
Block::Dirt
} else {
Block::Stone
};
chunk.set(x, y, z, b);
}
// Occasional tree
let tree_hash = hash2(ox + x + 1000, oz + z - 1000);
if tree_hash > 0.93 && height >= 18 && height < CHUNK_HEIGHT - 8 {
for ty in 0..4 {
chunk.set(x, height + ty, z, Block::Wood);
}
for dx in -2..=2_i32 {
for dz in -2..=2_i32 {
for dy in 3..=5_i32 {
if dx.abs() + dz.abs() + (dy - 4).abs() <= 3 {
let lx = x + dx;
let lz = z + dz;
if lx >= 0 && lx < CHUNK_SIZE && lz >= 0 && lz < CHUNK_SIZE {
let ly = height + dy;
if ly < CHUNK_HEIGHT && chunk.get(lx, ly, lz) == Block::Air {
chunk.set(lx, ly, lz, Block::Leaves);
}
}
}
}
}
}
}
}
}
chunk
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn natural_surface_y_is_deterministic() {
for &(x, z) in &[(0, 0), (-100, 5), (123, -456), (8, 8)] {
let a = natural_surface_y(x, z);
let b = natural_surface_y(x, z);
assert_eq!(a, b);
}
}
#[test]
fn natural_surface_y_in_range() {
for x in -200..=200 {
for z in -200..=200 {
let y = natural_surface_y(x, z);
assert!((0..CHUNK_HEIGHT).contains(&y), "out of range at ({},{})", x, z);
}
}
}
#[test]
fn raycast_hits_terrain_below_spawn() {
let world = World::new();
let surface = natural_surface_y(0, 0);
let origin = Vec3::new(0.5, surface as f32 + 12.0, 0.5);
let hit = world.raycast(origin, Vec3::new(0.0, -1.0, 0.0), 30.0);
let (hit_pos, _) = hit.expect("ray fired down at terrain must hit");
assert_eq!(hit_pos.y, surface, "ray must hit topmost solid block");
}
#[test]
fn raycast_misses_into_open_sky() {
let world = World::new();
let origin = Vec3::new(0.5, 60.0, 0.5);
let hit = world.raycast(origin, Vec3::new(0.0, 1.0, 0.0), 100.0);
assert!(hit.is_none(), "shooting up into open sky must miss");
}
#[test]
fn raycast_prev_pos_is_adjacent_to_hit() {
let world = World::new();
let surface = natural_surface_y(0, 0);
let origin = Vec3::new(0.5, surface as f32 + 5.0, 0.5);
let (hit, prev) = world
.raycast(origin, Vec3::new(0.0, -1.0, 0.0), 20.0)
.expect("must hit");
let delta = prev - hit;
let manhattan = delta.x.abs() + delta.y.abs() + delta.z.abs();
assert_eq!(manhattan, 1, "prev must be one block-step from hit");
}
}

15
web/.eslintrc.json Normal file
View file

@ -0,0 +1,15 @@
{
"root": true,
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"no-undef": "error",
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }]
}
}

632
web/index.html Normal file
View file

@ -0,0 +1,632 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Voxel Game</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<style>
:root {
--ui-bg: rgba(0,0,0,0.45);
--ui-fg: #f0f0f0;
}
html, body {
margin: 0;
height: 100%;
background: #1a1a1a;
color: var(--ui-fg);
font-family: system-ui, sans-serif;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: none;
overscroll-behavior: none;
}
#game-canvas {
display: block;
width: 100vw;
height: 100dvh;
cursor: crosshair;
image-rendering: pixelated;
touch-action: none;
}
#hud {
position: fixed;
top: 12px;
left: 12px;
background: var(--ui-bg);
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
pointer-events: none;
}
#hud kbd {
background: #333;
border-radius: 3px;
padding: 1px 5px;
border: 1px solid #555;
font-size: 11px;
}
#crosshair {
position: fixed;
left: 50%; top: 50%;
width: 14px; height: 14px;
margin-left: -7px; margin-top: -7px;
pointer-events: none;
}
#crosshair::before, #crosshair::after {
content: "";
position: absolute;
background: #fff;
mix-blend-mode: difference;
}
#crosshair::before { left: 6px; top: 0; width: 2px; height: 14px; }
#crosshair::after { top: 6px; left: 0; height: 2px; width: 14px; }
/* Connection status / name */
#net-status {
position: fixed;
top: 12px; right: 12px;
background: var(--ui-bg);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
display: flex;
gap: 8px;
align-items: center;
}
#net-status .dot {
width: 9px; height: 9px; border-radius: 50%;
background: #d33; transition: background 0.3s;
}
#net-status.connected .dot { background: #6c6; }
#gp-indicator {
display: none;
font-size: 14px;
line-height: 1;
filter: grayscale(40%);
opacity: 0.8;
}
#gp-indicator.active { display: inline; filter: none; opacity: 1; }
#net-status input {
background: rgba(0,0,0,0.3);
border: 1px solid #555;
border-radius: 3px;
color: #eee;
font-size: 12px;
padding: 2px 6px;
width: 110px;
font-family: inherit;
}
/* HP bar */
#hp {
position: fixed;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
width: 210px;
height: 18px;
background: rgba(0,0,0,0.5);
border: 2px solid rgba(255,255,255,0.25);
border-radius: 4px;
overflow: hidden;
}
#hp-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #e64141, #f0a040);
transition: width 0.2s;
}
#hp-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
text-shadow: 0 1px 2px #000;
}
/* Hotbar — must sit above the touch overlay (z 5) so taps land on slots
instead of being intercepted by the look pad. */
#hotbar {
position: fixed;
bottom: max(14px, env(safe-area-inset-bottom, 0));
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 5px;
background: var(--ui-bg);
padding: 5px;
border-radius: 8px;
pointer-events: auto;
z-index: 30;
touch-action: manipulation;
}
.slot {
width: 44px;
height: 44px;
border: 2px solid #555;
border-radius: 6px;
display: flex;
align-items: end;
justify-content: center;
padding-bottom: 3px;
font-size: 10px;
text-align: center;
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.85);
cursor: pointer;
user-select: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4) inset;
}
.slot.active {
border-color: #fff;
box-shadow: 0 0 0 2px rgba(255,255,255,0.30) inset, 0 0 8px rgba(255,255,255,0.35);
}
/* Narrow viewports: shrink slots so all 10 fit in one row. */
@media (max-width: 620px) {
.slot { width: 34px; height: 34px; font-size: 8px; padding-bottom: 2px; }
#hotbar { gap: 3px; padding: 4px; }
}
@media (max-width: 400px) {
.slot { width: 30px; height: 30px; font-size: 7px; padding-bottom: 2px; }
#hotbar { gap: 2px; padding: 3px; }
}
/* Menu */
#menu {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(3px);
display: none;
align-items: center;
justify-content: center;
z-index: 60;
}
body.menu-open #menu { display: flex; }
#menu-card {
background: rgba(28, 28, 32, 0.95);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
padding: 28px 32px;
width: 360px;
max-width: 92vw;
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
color: #f0f0f0;
font-size: 14px;
}
#menu-card h2 {
margin: 0 0 18px;
font-size: 22px;
letter-spacing: 1px;
text-align: center;
}
.menu-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 10px 0;
gap: 12px;
}
.menu-row label { flex: 1; }
.menu-row .value { width: 56px; text-align: right; opacity: 0.85; }
.menu-row input[type="range"] {
flex: 2;
accent-color: #8aa;
}
.menu-row input[type="text"] {
flex: 2;
background: rgba(0,0,0,0.3);
border: 1px solid #444;
border-radius: 4px;
color: inherit;
padding: 4px 8px;
font-family: inherit;
font-size: 13px;
}
.seg-toggle {
flex: 2;
display: flex;
gap: 0;
border: 1px solid #444;
border-radius: 5px;
overflow: hidden;
}
.seg-toggle button {
flex: 1;
background: rgba(0,0,0,0.3);
color: #ccc;
border: none;
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.seg-toggle button + button { border-left: 1px solid #444; }
.seg-toggle button.active {
background: #6a9;
color: #001;
font-weight: 700;
}
.menu-actions {
display: flex;
gap: 10px;
margin-top: 18px;
}
.menu-actions button {
flex: 1;
padding: 10px;
background: #6a9;
color: #001;
border: none;
border-radius: 5px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.menu-actions button.secondary {
background: #444;
color: #ddd;
}
.menu-actions button:active { transform: translateY(1px); }
#menu-tip {
margin-top: 14px;
font-size: 11px;
opacity: 0.55;
text-align: center;
}
#menu-btn {
/* Must outrank #touch-overlay (z 5) — otherwise on mobile the look pad
sits on top of the button and eats the tap. */
position: fixed;
top: 12px;
right: 280px;
background: var(--ui-bg);
border: 1px solid rgba(255,255,255,0.35);
color: #fff;
width: 44px;
height: 44px;
border-radius: 6px;
font-size: 22px;
line-height: 1;
cursor: pointer;
display: none;
z-index: 30;
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
}
#menu-btn:active { background: rgba(255,255,255,0.25); }
body.touch #menu-btn {
display: flex;
align-items: center;
justify-content: center;
right: 12px;
top: 60px;
}
/* Gamepad debug overlay — must outrank the settings menu (z 60) since
"Test gamepad…" is opened from inside the menu and we need to actually
see the overlay land on top of it. */
#gptest {
position: fixed;
right: 12px;
bottom: 80px;
z-index: 70;
width: 320px;
max-width: 92vw;
}
#gptest-card {
background: rgba(20, 20, 24, 0.94);
border: 1px solid rgba(255,255,255,0.18);
border-radius: 8px;
padding: 12px 14px;
color: #e7e7e7;
font-size: 13px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
#gptest button {
background: #555; color: #eee; border: none;
padding: 4px 10px; border-radius: 4px;
font-family: inherit; font-size: 11px; cursor: pointer;
}
.gp-btn {
width: 22px; height: 22px;
border-radius: 4px;
background: rgba(80,80,80,0.5);
border: 1px solid rgba(255,255,255,0.15);
display: flex; align-items: center; justify-content: center;
font-size: 9px; color: #999;
}
.gp-btn.on {
background: #6c6; color: #001; font-weight: 700;
}
.gp-stick {
width: 60px; height: 60px;
border-radius: 50%;
background: rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.25);
position: relative;
}
.gp-stick-dot {
position: absolute;
width: 12px; height: 12px;
border-radius: 50%;
background: #ddd;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
transition: transform 0.05s linear;
}
.gp-stick-label {
font-size: 9px; opacity: 0.55; text-align: center; margin-top: 3px;
}
/* Death screen */
#death {
position: fixed;
inset: 0;
background: rgba(120, 0, 0, 0.55);
backdrop-filter: blur(2px);
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 24px;
z-index: 100;
}
body.dead #death { display: flex; }
#death h1 {
font-size: 64px;
margin: 0;
color: #fff;
text-shadow: 0 4px 12px #000;
letter-spacing: 4px;
}
#death button {
padding: 12px 36px;
font-size: 18px;
background: #fff;
color: #200;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
}
#death button:active { transform: translateY(1px); }
/* Touch UI — MCPE-style: joystick left, action stack right */
body.touch #hud { display: none; }
#touch-overlay {
position: fixed;
inset: 0;
pointer-events: none; /* children opt in */
z-index: 5;
display: none;
}
body.touch #touch-overlay { display: block; }
body.touch #crosshair { display: block; }
/* Look pad sits underneath everything else, full screen, transparent.
pointer events on stick/buttons land on them first because of z-index. */
#look-pad {
position: absolute;
inset: 0;
background: transparent;
pointer-events: auto;
touch-action: none;
z-index: 1;
}
#stick {
position: absolute;
left: max(28px, env(safe-area-inset-left, 0));
bottom: max(28px, env(safe-area-inset-bottom, 0));
width: 160px; height: 160px;
border-radius: 50%;
background: rgba(0,0,0,0.22);
border: 2px solid rgba(255,255,255,0.28);
pointer-events: auto;
touch-action: none;
z-index: 2;
box-shadow: 0 4px 18px rgba(0,0,0,0.35);
}
#stick-knob {
position: absolute;
width: 64px; height: 64px;
border-radius: 50%;
background: rgba(255,255,255,0.55);
left: 50%; top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
transition: background 0.15s;
box-shadow: 0 2px 6px rgba(0,0,0,0.3) inset;
}
#stick.engaged #stick-knob { background: rgba(255,255,255,0.85); }
#touch-actions {
position: absolute;
right: max(20px, env(safe-area-inset-right, 0));
bottom: max(28px, env(safe-area-inset-bottom, 0));
display: flex;
flex-direction: column-reverse; /* jump on the bottom (thumb), break/place above */
gap: 14px;
pointer-events: none;
z-index: 2;
}
.tbtn {
width: 72px; height: 72px;
border-radius: 50%;
background: rgba(0,0,0,0.32);
border: 2px solid rgba(255,255,255,0.32);
color: #fff;
font-size: 22px;
font-weight: 700;
display: flex;
align-items: center; justify-content: center;
pointer-events: auto;
touch-action: none;
user-select: none;
box-shadow: 0 3px 12px rgba(0,0,0,0.3);
}
.tbtn:active, .tbtn.pressed {
background: rgba(255,255,255,0.32);
transform: translateY(1px);
}
#btn-jump { width: 90px; height: 90px; font-size: 28px; }
#btn-break { background: rgba(180,40,40,0.40); }
#btn-place { background: rgba(40,140,40,0.40); }
#btn-sprint { background: rgba(80,80,140,0.40); font-size: 18px; }
/* Hide the top-right name input on touch — name lives in the menu. */
body.touch #net-status #player-name { display: none; }
</style>
</head>
<body>
<canvas id="game-canvas" width="1280" height="720" tabindex="0"></canvas>
<div id="crosshair"></div>
<div id="hud">
<strong>Voxel Game</strong><br/>
<kbd>WASD</kbd> move &nbsp; <kbd>Space</kbd> jump &nbsp; <kbd>Ctrl</kbd> sprint<br/>
Click canvas to lock mouse · <kbd>LMB</kbd> break · <kbd>RMB</kbd> place<br/>
<kbd>1</kbd><kbd>6</kbd> pick block
</div>
<div id="net-status">
<span class="dot"></span>
<span id="net-text">offline</span>
<span id="gp-indicator" title="Gamepad detected">🎮</span>
<input id="player-name" placeholder="name" maxlength="24" />
</div>
<div id="hp">
<div id="hp-fill"></div>
<div id="hp-label">20 / 20</div>
</div>
<div id="hotbar">
<div class="slot" data-b="1" style="background:#5fbb52">grass</div>
<div class="slot" data-b="2" style="background:#8c5c33">dirt</div>
<div class="slot active" data-b="3" style="background:#808284">stone</div>
<div class="slot" data-b="4" style="background:#e0d18c">sand</div>
<div class="slot" data-b="5" style="background:#6b4d2d">wood</div>
<div class="slot" data-b="6" style="background:#338b39">leaves</div>
<div class="slot" data-b="7" style="background:#6b6b73">cobble</div>
<div class="slot" data-b="8" style="background:#a64d38">brick</div>
<div class="slot" data-b="9" style="background:#f2f4f7;color:#222;text-shadow:none">snow</div>
<div class="slot" data-b="10" style="background:#9ed1f0;color:#1a3344;text-shadow:none">ice</div>
</div>
<div id="touch-overlay" aria-hidden="true">
<div id="look-pad"></div>
<div id="stick"><div id="stick-knob"></div></div>
<div id="touch-actions">
<button id="btn-jump" class="tbtn" type="button"></button>
<button id="btn-place" class="tbtn" type="button"></button>
<button id="btn-break" class="tbtn" type="button"></button>
<button id="btn-sprint" class="tbtn" type="button">»</button>
</div>
</div>
<div id="death">
<h1>YOU DIED</h1>
<button id="respawn-btn">RESPAWN</button>
</div>
<button id="menu-btn" title="Menu (Esc)"></button>
<div id="gptest" style="display:none;">
<div id="gptest-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<strong>Gamepad debug</strong>
<button id="gptest-close" type="button">close</button>
</div>
<div id="gptest-info" style="font-size:11px; opacity:0.7; margin-bottom:8px;">no controller detected — press any button or move a stick</div>
<div style="display:flex; gap:14px; align-items:center; margin-bottom:8px;">
<div>
<div class="gp-stick"><div class="gp-stick-dot" id="gp-stick-l"></div></div>
<div class="gp-stick-label" id="gp-stick-l-label">L stick</div>
</div>
<div>
<div class="gp-stick"><div class="gp-stick-dot" id="gp-stick-r"></div></div>
<div class="gp-stick-label" id="gp-stick-r-label">R stick</div>
</div>
<div style="flex:1; font-family:monospace; font-size:10px; line-height:1.4;" id="gptest-axes"></div>
</div>
<div id="gptest-buttons" style="display:flex; flex-wrap:wrap; gap:4px;"></div>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1); font-size: 11px;">
<div style="opacity:0.7; margin-bottom: 5px;">
Stick calibration — push stick fully in the named direction, then click.
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 4px;">
<button id="gp-cal-lx" type="button">L stick ←→</button>
<button id="gp-cal-ly" type="button">L stick ↑↓</button>
<button id="gp-cal-rx" type="button">R stick ←→</button>
<button id="gp-cal-ry" type="button">R stick ↑↓</button>
</div>
<div id="gp-mapping" style="margin-top: 6px; font-family:monospace; opacity: 0.75;"></div>
<button id="gp-cal-reset" type="button" style="margin-top: 6px;">reset to defaults (0,1,2,3)</button>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
<div style="opacity:0.7; margin-bottom: 4px;">
Path verification — these bypass the gamepad and send input straight to the engine.
If clicking these moves you, the game itself is fine; the problem is upstream (Steam Input, etc).
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 4px;">
<button id="gp-test-fwd" type="button">▲ walk fwd 1s</button>
<button id="gp-test-jump" type="button">▼ jump once</button>
</div>
</div>
<div id="gp-kbd-state" style="margin-top: 8px; font-family:monospace; opacity:0.7;">kbd: (waiting)</div>
</div>
</div>
</div>
<div id="menu">
<div id="menu-card">
<h2>SETTINGS</h2>
<div class="menu-row">
<label>Input mode</label>
<div class="seg-toggle">
<button id="mode-pc" type="button">PC</button>
<button id="mode-mobile" type="button">Mobile</button>
</div>
</div>
<div class="menu-row">
<label for="set-name">Player name</label>
<input id="set-name" type="text" maxlength="24" />
</div>
<div class="menu-row">
<label for="set-sens">Mouse sensitivity</label>
<input id="set-sens" type="range" min="0.001" max="0.02" step="0.0005" />
<span class="value" id="set-sens-val"></span>
</div>
<div class="menu-row">
<label for="set-fov">FOV</label>
<input id="set-fov" type="range" min="40" max="110" step="1" />
<span class="value" id="set-fov-val"></span>
</div>
<div class="menu-row">
<label for="set-dist">Render distance</label>
<input id="set-dist" type="range" min="64" max="800" step="16" />
<span class="value" id="set-dist-val"></span>
</div>
<div class="menu-actions">
<button id="menu-resume">RESUME</button>
<button id="menu-respawn" class="secondary">Respawn</button>
</div>
<div class="menu-actions" style="margin-top: 8px;">
<button id="menu-gptest" class="secondary">Test gamepad…</button>
</div>
<div id="menu-tip">Esc to toggle · settings save automatically</div>
</div>
</div>
<script type="module" src="./main.js"></script>
</body>
</html>

665
web/main.js Normal file
View file

@ -0,0 +1,665 @@
import init, * as wasm from "./voxel_game.js";
const detectedTouch =
(("ontouchstart" in window) || navigator.maxTouchPoints > 0)
&& !window.matchMedia("(pointer:fine)").matches;
let inputMode = localStorage.getItem("voxel-input-mode")
|| (detectedTouch ? "mobile" : "pc");
function applyInputMode(mode) {
inputMode = mode;
localStorage.setItem("voxel-input-mode", mode);
document.body.classList.toggle("touch", mode === "mobile");
wasm.set_touch_mode(mode === "mobile");
wasm.reset_input();
const pcBtn = document.getElementById("mode-pc");
const mbBtn = document.getElementById("mode-mobile");
if (pcBtn && mbBtn) {
pcBtn.classList.toggle("active", mode === "pc");
mbBtn.classList.toggle("active", mode === "mobile");
}
}
init().then(() => {
wasm.reset_input();
setupTouch();
setupGamepad();
setupHotbar();
setupDeathScreen();
setupStatusLoop();
setupNetwork();
setupMenu();
applyInputMode(inputMode);
document.addEventListener("visibilitychange", () => {
if (document.hidden) wasm.reset_input();
});
window.addEventListener("blur", () => wasm.reset_input());
}).catch(err => {
console.error(err);
document.body.insertAdjacentHTML("beforeend",
`<pre style="position:fixed;bottom:10px;left:10px;color:#f88;background:#000;padding:10px;max-width:90vw;white-space:pre-wrap">${err}</pre>`);
});
function setupHotbar() {
const slots = document.querySelectorAll("#hotbar .slot");
slots.forEach((s, idx) => {
s.addEventListener("pointerdown", (e) => {
e.preventDefault();
const b = parseInt(s.dataset.b, 10);
wasm.select_block(b);
slots.forEach(x => x.classList.remove("active"));
s.classList.add("active");
_selectedSlot = idx;
});
});
window.addEventListener("keydown", (e) => {
const map = {
"Digit1": 0, "Digit2": 1, "Digit3": 2, "Digit4": 3, "Digit5": 4,
"Digit6": 5, "Digit7": 6, "Digit8": 7, "Digit9": 8, "Digit0": 9,
};
if (e.code in map) {
const idx = map[e.code];
slots.forEach((x, i) => x.classList.toggle("active", i === idx));
_selectedSlot = idx;
}
});
// Mouse-wheel cycles the hotbar like Minecraft does.
window.addEventListener("wheel", (e) => {
if (document.body.classList.contains("menu-open")) return;
if (document.body.classList.contains("dead")) return;
if (e.deltaY === 0) return;
e.preventDefault();
cycleHotbar(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
}
function setupDeathScreen() {
document.getElementById("respawn-btn").addEventListener("click", (e) => {
e.preventDefault();
wasm.respawn();
});
}
function setupMenu() {
const canvas = document.getElementById("game-canvas");
const sens = document.getElementById("set-sens");
const sensVal = document.getElementById("set-sens-val");
const fov = document.getElementById("set-fov");
const fovVal = document.getElementById("set-fov-val");
const dist = document.getElementById("set-dist");
const distVal = document.getElementById("set-dist-val");
const name = document.getElementById("set-name");
const saved = JSON.parse(localStorage.getItem("voxel-settings") || "{}");
sens.value = saved.sens ?? 0.005;
fov.value = saved.fov ?? 70;
dist.value = saved.dist ?? 240;
name.value = localStorage.getItem("voxel-name") || "";
const topName = document.getElementById("player-name");
if (topName) topName.value = name.value;
const apply = () => {
const sv = parseFloat(sens.value);
const fv = parseFloat(fov.value);
const dv = parseFloat(dist.value);
wasm.set_mouse_sens(sv);
wasm.set_fov(fv);
wasm.set_render_distance(dv);
sensVal.textContent = sv.toFixed(4);
fovVal.textContent = fv + "°";
distVal.textContent = dv + " bl";
localStorage.setItem("voxel-settings", JSON.stringify({ sens: sv, fov: fv, dist: dv }));
};
sens.addEventListener("input", apply);
fov.addEventListener("input", apply);
dist.addEventListener("input", apply);
apply();
const pushName = () => {
localStorage.setItem("voxel-name", name.value);
wasm.set_player_name(name.value || "");
if (topName) topName.value = name.value;
};
name.addEventListener("change", pushName);
let everLocked = false;
const openMenu = () => {
document.body.classList.add("menu-open");
wasm.set_paused(true);
};
const closeMenu = () => {
document.body.classList.remove("menu-open");
wasm.set_paused(false);
};
document.addEventListener("pointerlockchange", () => {
const locked = document.pointerLockElement === canvas;
if (locked) {
everLocked = true;
closeMenu();
} else if (everLocked && inputMode !== "mobile") {
openMenu();
}
});
document.getElementById("menu-resume").addEventListener("click", (e) => {
e.preventDefault();
if (inputMode === "mobile") {
closeMenu();
} else {
canvas.requestPointerLock();
}
});
document.getElementById("menu-respawn").addEventListener("click", (e) => {
e.preventDefault();
wasm.respawn();
});
document.getElementById("mode-pc").addEventListener("click", (e) => {
e.preventDefault();
applyInputMode("pc");
});
document.getElementById("mode-mobile").addEventListener("click", (e) => {
e.preventDefault();
applyInputMode("mobile");
});
document.getElementById("menu-btn").addEventListener("click", (e) => {
e.preventDefault();
if (document.body.classList.contains("menu-open")) {
closeMenu();
} else {
openMenu();
}
});
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && inputMode === "mobile") {
if (document.body.classList.contains("menu-open")) closeMenu();
else openMenu();
}
});
}
function setupStatusLoop() {
const hpFill = document.getElementById("hp-fill");
const hpLabel = document.getElementById("hp-label");
setInterval(() => {
const hp = wasm.get_hp();
const alive = wasm.is_alive();
hpFill.style.width = (hp / 20 * 100) + "%";
hpLabel.textContent = `${hp} / 20`;
document.body.classList.toggle("dead", !alive);
}, 100);
}
function setupNetwork() {
const nameInput = document.getElementById("player-name");
const savedName = localStorage.getItem("voxel-name") || "";
nameInput.value = savedName;
const sendName = () => {
localStorage.setItem("voxel-name", nameInput.value);
wasm.set_player_name(nameInput.value || "");
};
nameInput.addEventListener("change", sendName);
sendName();
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${location.host}/ws`;
let ws = null;
let pumpTimer = null;
const setStatus = (text, connected) => {
document.getElementById("net-text").textContent = text;
document.getElementById("net-status").classList.toggle("connected", connected);
};
const pump = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const items = wasm.drain_outbox();
for (const s of items) {
try { ws.send(s); } catch (e) { break; }
}
};
const connect = () => {
setStatus("connecting…", false);
try {
ws = new WebSocket(url);
} catch (e) {
setStatus("server unreachable", false);
setTimeout(connect, 2500);
return;
}
ws.onopen = () => {
setStatus("connected", true);
wasm.on_ws_open();
pumpTimer = setInterval(pump, 50);
};
ws.onmessage = (ev) => {
wasm.on_ws_message(ev.data);
};
ws.onclose = () => {
setStatus("disconnected · retrying", false);
wasm.on_ws_close();
if (pumpTimer) { clearInterval(pumpTimer); pumpTimer = null; }
setTimeout(connect, 2000);
};
ws.onerror = () => {};
};
connect();
}
function setupTouch() {
wasm.touch_move(false, false, false, false);
wasm.touch_jump(false);
wasm.touch_sprint(false);
const stick = document.getElementById("stick");
const knob = document.getElementById("stick-knob");
let stickRect = null;
let stickPointerId = null;
const stickReset = () => {
knob.style.left = "50%";
knob.style.top = "50%";
stick.classList.remove("engaged");
wasm.touch_move(false, false, false, false);
};
const stickApply = (e) => {
if (!stickRect) return;
const cx = stickRect.left + stickRect.width / 2;
const cy = stickRect.top + stickRect.height / 2;
let dx = (e.clientX - cx) / (stickRect.width / 2);
let dy = (e.clientY - cy) / (stickRect.height / 2);
const m = Math.hypot(dx, dy);
if (m > 1.0) { dx /= m; dy /= m; }
knob.style.left = (50 + dx * 38) + "%";
knob.style.top = (50 + dy * 38) + "%";
const dz = 0.30;
wasm.touch_move(dy < -dz, dy > dz, dx < -dz, dx > dz);
};
stick.addEventListener("pointerdown", (e) => {
if (stickPointerId !== null) return;
e.preventDefault();
stickPointerId = e.pointerId;
stickRect = stick.getBoundingClientRect();
stick.setPointerCapture(e.pointerId);
stick.classList.add("engaged");
stickApply(e);
});
stick.addEventListener("pointermove", (e) => {
if (e.pointerId !== stickPointerId) return;
stickApply(e);
});
const stickEnd = (e) => {
if (e.pointerId !== stickPointerId) return;
stickPointerId = null;
stickRect = null;
stickReset();
};
stick.addEventListener("pointerup", stickEnd);
stick.addEventListener("pointercancel", stickEnd);
const look = document.getElementById("look-pad");
const lookers = new Map();
look.addEventListener("pointerdown", (e) => {
if (e.pointerType === "mouse") return;
e.preventDefault();
look.setPointerCapture(e.pointerId);
lookers.set(e.pointerId, { x: e.clientX, y: e.clientY });
});
look.addEventListener("pointermove", (e) => {
const prev = lookers.get(e.pointerId);
if (!prev) return;
const dx = e.clientX - prev.x;
const dy = e.clientY - prev.y;
lookers.set(e.pointerId, { x: e.clientX, y: e.clientY });
wasm.touch_look(dx * 0.55, dy * 0.55);
});
const lookEnd = (e) => lookers.delete(e.pointerId);
look.addEventListener("pointerup", lookEnd);
look.addEventListener("pointercancel", lookEnd);
look.addEventListener("pointerleave", lookEnd);
const hold = (id, setter) => {
const el = document.getElementById(id);
let activeId = null;
el.addEventListener("pointerdown", (e) => {
if (activeId !== null) return;
e.preventDefault();
activeId = e.pointerId;
el.setPointerCapture(e.pointerId);
el.classList.add("pressed");
setter(true);
});
const off = (e) => {
if (e.pointerId !== activeId) return;
activeId = null;
el.classList.remove("pressed");
setter(false);
};
el.addEventListener("pointerup", off);
el.addEventListener("pointercancel", off);
el.addEventListener("pointerleave", off);
};
const tap = (id, fn) => {
const el = document.getElementById(id);
el.addEventListener("pointerdown", (e) => {
e.preventDefault();
el.classList.add("pressed");
fn();
setTimeout(() => el.classList.remove("pressed"), 120);
});
};
hold("btn-jump", wasm.touch_jump);
hold("btn-sprint", wasm.touch_sprint);
tap("btn-break", wasm.touch_break);
tap("btn-place", wasm.touch_place);
}
function setupGamepad() {
let prev = [];
let lastSeenId = null;
// ---- Stick axis mapping, calibrated by the user via the test overlay ----
// Defaults match the W3C "standard" gamepad mapping (sticks on axes 0..3).
// The Steam Deck and other devices sometimes expose them elsewhere
// (trackpads-as-sticks, gyro, etc), so we let the user record which axis
// their physical stick actually drives.
const defaultMap = { lx: 0, ly: 1, rx: 2, ry: 3 };
let stickMap = { ...defaultMap, ...(JSON.parse(localStorage.getItem("voxel-gp-map") || "{}")) };
const saveMap = () => localStorage.setItem("voxel-gp-map", JSON.stringify(stickMap));
const updateMapText = () => {
const el = document.getElementById("gp-mapping");
if (el) {
el.textContent =
`axes: L=(${stickMap.lx},${stickMap.ly}) R=(${stickMap.rx},${stickMap.ry})`;
}
};
const gpIndicator = document.getElementById("gp-indicator");
const testCard = document.getElementById("gptest");
const testInfo = document.getElementById("gptest-info");
const testAxes = document.getElementById("gptest-axes");
const testButtons = document.getElementById("gptest-buttons");
document.getElementById("menu-gptest").addEventListener("click", (e) => {
e.preventDefault();
// Close the settings menu so the test overlay isn't hidden behind it
// and the engine is unpaused so the "walk fwd 1s" verification actually
// makes the player move.
document.body.classList.remove("menu-open");
wasm.set_paused(false);
testCard.style.display = "block";
updateMapText();
});
document.getElementById("gptest-close").addEventListener("click", (e) => {
e.preventDefault();
testCard.style.display = "none";
});
// Calibration: pick the most-displaced axis right now and assign it.
const captureAxis = (slot) => {
const gp = firstConnectedGamepad();
if (!gp) {
alert("No controller detected. Press any button on your controller so the browser exposes the gamepad, then try again.");
return;
}
// Dump the full axis snapshot to console for diagnosis regardless of
// whether we end up capturing — invaluable when nothing seems to move.
const snapshot = [];
for (let i = 0; i < gp.axes.length; i++) snapshot.push(`${i}=${safeAxis(gp, i).toFixed(3)}`);
console.log(`[gamepad] axes at calibration: ${snapshot.join(" ")}`);
let bestI = -1;
let bestV = 0.10; // be permissive — Steam Input often scales sticks down
for (let i = 0; i < gp.axes.length; i++) {
const v = Math.abs(safeAxis(gp, i));
if (v > bestV) { bestV = v; bestI = i; }
}
if (bestI < 0) {
alert(
"No axis moved past 0.10 right now. This usually means Steam Input is intercepting your stick (mapping it to mouse or keyboard) and the browser never sees it as an axis at all.\n\n" +
"Check console: I just logged the full axis state. If everything is 0.000, your stick is not reaching the browser.\n\n" +
"Fix path: Steam → Settings → Controller → Desktop Layout → set the joystick behavior to \"Joystick\" (not Mouse / WASD)."
);
return;
}
stickMap[slot] = bestI;
saveMap();
updateMapText();
console.log(`[gamepad] mapped ${slot} → axis ${bestI} (full map: ${JSON.stringify(stickMap)})`);
};
document.getElementById("gp-cal-lx").addEventListener("click", (e) => { e.preventDefault(); captureAxis("lx"); });
document.getElementById("gp-cal-ly").addEventListener("click", (e) => { e.preventDefault(); captureAxis("ly"); });
document.getElementById("gp-cal-rx").addEventListener("click", (e) => { e.preventDefault(); captureAxis("rx"); });
document.getElementById("gp-cal-ry").addEventListener("click", (e) => { e.preventDefault(); captureAxis("ry"); });
document.getElementById("gp-cal-reset").addEventListener("click", (e) => {
e.preventDefault();
stickMap = { ...defaultMap };
saveMap();
updateMapText();
});
// Path-verification: send input straight to the engine, bypassing any
// controller mystery. If walk-forward works here, the wasm side is fine
// and the problem is the input source.
document.getElementById("gp-test-fwd").addEventListener("click", (e) => {
e.preventDefault();
console.log("[gamepad-test] firing touch_move(forward) for 1s");
wasm.touch_move(true, false, false, false);
setTimeout(() => wasm.touch_move(false, false, false, false), 1000);
});
document.getElementById("gp-test-jump").addEventListener("click", (e) => {
e.preventDefault();
console.log("[gamepad-test] firing touch_jump press/release");
wasm.touch_jump(true);
setTimeout(() => wasm.touch_jump(false), 250);
});
// Live keyboard echo so we can tell whether Steam Input is translating
// sticks into WASD presses that Chrome actually receives.
const kbdEl = document.getElementById("gp-kbd-state");
const pressed = new Set();
window.addEventListener("keydown", (e) => {
if (!kbdEl) return;
pressed.add(e.code);
kbdEl.textContent = "kbd: " + [...pressed].join(" ");
});
window.addEventListener("keyup", (e) => {
if (!kbdEl) return;
pressed.delete(e.code);
kbdEl.textContent = "kbd: " + ([...pressed].join(" ") || "(none)");
});
window.addEventListener("gamepadconnected", (e) => {
console.log(`[gamepad] connected: ${e.gamepad.id} | mapping=${e.gamepad.mapping || "(none)"} | axes=${e.gamepad.axes.length} | buttons=${e.gamepad.buttons.length}`);
});
window.addEventListener("gamepaddisconnected", (e) => {
console.log(`[gamepad] disconnected: ${e.gamepad.id}`);
prev = [];
wasm.reset_input();
});
// Smooth deadzone — rescales (deadzone..1) into (0..1) so just past the
// deadzone you get a true 0, not a sudden jump to 0.15.
const applyDeadzone = (v, t = 0.15) => {
if (Math.abs(v) < t) return 0;
return (v - Math.sign(v) * t) / (1 - t);
};
const firstConnectedGamepad = () => {
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (const p of pads) {
// Some browsers leave nulls in the array even after disconnect.
// `connected` defaults to true if not set explicitly.
if (p && (p.connected !== false)) return p;
}
return null;
};
const safeAxis = (gp, i) => {
const v = gp.axes && gp.axes[i];
return typeof v === "number" ? v : 0;
};
const buttonValue = (b) => {
if (!b) return 0;
if (typeof b === "object") return b.value || (b.pressed ? 1 : 0);
return typeof b === "number" ? b : 0;
};
const isDown = (gp, i) => buttonValue(gp.buttons && gp.buttons[i]) > 0.5;
const stickL = document.getElementById("gp-stick-l");
const stickR = document.getElementById("gp-stick-r");
const moveStick = (el, x, y) => {
if (!el) return;
const r = 24;
const cx = Math.max(-1, Math.min(1, x)) * r;
const cy = Math.max(-1, Math.min(1, y)) * r;
el.style.transform = `translate(calc(-50% + ${cx}px), calc(-50% + ${cy}px))`;
el.style.background = (Math.abs(x) + Math.abs(y)) > 0.05 ? "#6c6" : "#ddd";
};
const renderTestOverlay = (gp) => {
if (!testCard || testCard.style.display === "none") return;
if (!gp) {
// Always show what the API itself is reporting so we can tell
// "browser sees no gamepad at all" from "browser sees one but we
// can't read it". This is the single most useful diagnostic.
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
const slots = (pads.length ? pads : [null]).map((p, i) =>
p ? `slot ${i}: ${p.id}` : `slot ${i}: (empty)`
).join("\n");
testInfo.style.whiteSpace = "pre-wrap";
testInfo.textContent =
`No controller detected. navigator.getGamepads():\n${slots}\n\n` +
`Press a button on your controller — most browsers don't expose ` +
`gamepads until they receive at least one input event.\n\n` +
`Steam Deck note: if Chrome was installed via Discover (Flatpak), ` +
`it may be sandboxed from /dev/input. Grant input access via Flatseal ` +
`or run "flatpak override --user --device=input com.google.Chrome".`;
testAxes.textContent = "";
testButtons.textContent = "";
moveStick(stickL, 0, 0);
moveStick(stickR, 0, 0);
return;
}
testInfo.style.whiteSpace = "normal";
testInfo.textContent = `${gp.id} · mapping: ${gp.mapping || "(none)"} · ${gp.axes.length} axes / ${gp.buttons.length} buttons`;
moveStick(stickL, safeAxis(gp, stickMap.lx), safeAxis(gp, stickMap.ly));
moveStick(stickR, safeAxis(gp, stickMap.rx), safeAxis(gp, stickMap.ry));
// Show all axes numerically (Steam Deck sometimes exposes >4 axes —
// trackpads, gyro — and this is how we'll spot if sticks are landing on
// an unexpected index).
const axisLines = [];
for (let i = 0; i < gp.axes.length; i++) {
const v = safeAxis(gp, i);
const bar = Math.abs(v) > 0.05 ? ` ${v >= 0 ? "+" : ""}${"█".repeat(Math.min(8, Math.floor(Math.abs(v) * 8)))}` : "";
axisLines.push(`${String(i).padStart(2)}: ${v.toFixed(2).padStart(6)}${bar}`);
}
testAxes.textContent = axisLines.join("\n");
testButtons.innerHTML = "";
for (let i = 0; i < gp.buttons.length; i++) {
const b = document.createElement("div");
b.className = "gp-btn" + (isDown(gp, i) ? " on" : "");
b.textContent = i;
testButtons.appendChild(b);
}
};
const tick = () => {
const gp = firstConnectedGamepad();
if (gp && gp.id !== lastSeenId) {
lastSeenId = gp.id;
console.log(`[gamepad] active: ${gp.id} | mapping=${gp.mapping || "(none)"} | axes=${gp.axes.length} | buttons=${gp.buttons.length}`);
}
if (gpIndicator) gpIndicator.classList.toggle("active", !!gp);
if (gp) {
// Lower deadzone — Deck sticks are Hall effect with very low natural
// drift, and Steam Input passes them through nearly raw. The previous
// 0.15 + 0.2 = 0.35 effective threshold meant gentle stick deflections
// didn't move the player at all.
const lx = applyDeadzone(safeAxis(gp, stickMap.lx), 0.10);
const ly = applyDeadzone(safeAxis(gp, stickMap.ly), 0.10);
const moveT = 0.18;
wasm.touch_move(ly < -moveT, ly > moveT, lx < -moveT, lx > moveT);
const rx = applyDeadzone(safeAxis(gp, stickMap.rx), 0.10);
const ry = applyDeadzone(safeAxis(gp, stickMap.ry), 0.10);
if (rx !== 0 || ry !== 0) {
wasm.touch_look(rx * 9.0, ry * 9.0);
}
// ---- Complete standard XInput button mapping ----
// 0 = A / Cross → jump (held)
// 1 = B / Circle → (unused — would be sneak)
// 2 = X / Square → break (one-shot)
// 3 = Y / Triangle → place (one-shot, alternate to RT)
// 4 = LB → previous hotbar slot
// 5 = RB → next hotbar slot
// 6 = LT → break (alternate, held → continuous? no, one-shot)
// 7 = RT → place (one-shot)
// 8 = Back / Select → toggle menu
// 9 = Start → toggle menu
// 10 = L3 (left stick) → sprint (held)
// 11 = R3 (right stick) → respawn (long-press feel)
// 12 = D-pad up → previous hotbar slot
// 13 = D-pad down → next hotbar slot
// 14 = D-pad left → previous hotbar slot
// 15 = D-pad right → next hotbar slot
// 16 = Home / Guide → (reserved by OS in most browsers)
const justPressed = (i) => isDown(gp, i) && !prev[i];
wasm.touch_jump(isDown(gp, 0));
wasm.touch_sprint(isDown(gp, 10));
if (justPressed(2)) wasm.touch_break();
if (justPressed(6)) wasm.touch_break();
if (justPressed(7)) wasm.touch_place();
if (justPressed(3)) wasm.touch_place();
if (justPressed(4) || justPressed(14)) cycleHotbar(-1);
if (justPressed(5) || justPressed(15)) cycleHotbar(+1);
if (justPressed(12)) cycleHotbar(-1);
if (justPressed(13)) cycleHotbar(+1);
if (justPressed(8) || justPressed(9)) {
document.getElementById("menu-btn").click();
}
if (justPressed(11)) wasm.respawn();
prev = [];
const len = gp.buttons ? gp.buttons.length : 0;
for (let i = 0; i < len; i++) prev[i] = isDown(gp, i);
} else {
if (lastSeenId !== null) {
lastSeenId = null;
prev = [];
}
}
renderTestOverlay(gp);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
let _selectedSlot = 2;
function cycleHotbar(delta) {
const slots = document.querySelectorAll("#hotbar .slot");
if (!slots.length) return;
_selectedSlot = (_selectedSlot + delta + slots.length) % slots.length;
slots.forEach((s, i) => s.classList.toggle("active", i === _selectedSlot));
const b = parseInt(slots[_selectedSlot].dataset.b, 10);
wasm.select_block(b);
}