commit 3a4ae970b233061b8ae316bb053988f52914d4c5 Author: Maximus Gorog Date: Fri May 22 23:33:47 2026 -0600 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ca335b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b12d23d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2535 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" +dependencies = [ + "log", + "web-sys", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "glow" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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 = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "naga" +version = "23.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "364f94bc34f61332abebe8cad6f6cd82a5b65cff22c828d05d0968911462ca4f" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "orbclient" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a570f6bca41d29acb2139229a7c873ec99bc9a313bd10804081d89bfac8ff329" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[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 = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[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 = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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 = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "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" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "voxel-game" +version = "0.1.0" +dependencies = [ + "bytemuck", + "console_error_panic_hook", + "console_log", + "env_logger", + "glam", + "js-sys", + "log", + "pollster", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu", + "winit", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80f70000db37c469ea9d67defdc13024ddf9a5f1b89cb2941b812ad7cde1735a" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d63c3c478de8e7e01786479919c8769f62a22eec16788d8c2ac77ce2c132778a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.11.1", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89364b8a0b211adc7b16aeaf1bd5ad4a919c1154b44c9ce27838213ba05fd821" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.11.1", + "block", + "bytemuck", + "cfg_aliases 0.1.1", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "23.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610f6ff27778148c31093f3b03abc4840f9636d58d597ca2f5977433acfe0068" +dependencies = [ + "bitflags 2.11.1", + "js-sys", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..248b33e --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/build-web.sh b/build-web.sh new file mode 100755 index 0000000..15ca371 --- /dev/null +++ b/build-web.sh @@ -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/" diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..3ceba6b --- /dev/null +++ b/run.sh @@ -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 diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..bf5a472 --- /dev/null +++ b/server/Cargo.lock @@ -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" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..595d42f --- /dev/null +++ b/server/Cargo.toml @@ -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" diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..4c21a58 --- /dev/null +++ b/server/src/main.rs @@ -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>, + players: Arc>>, + edits: Arc>>, + tx: broadcast::Sender, +} + +#[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::(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 = 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) -> 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::(&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"); +} diff --git a/server/src/proto.rs b/server/src/proto.rs new file mode 100644 index 0000000..c184d4e --- /dev/null +++ b/server/src/proto.rs @@ -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 }, + Players { list: Vec }, + 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, +} diff --git a/src/camera.rs b/src/camera.rs new file mode 100644 index 0000000..41bcf7b --- /dev/null +++ b/src/camera.rs @@ -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 + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d301ef8 --- /dev/null +++ b/src/lib.rs @@ -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); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7344501 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + voxel_game::run(); +} diff --git a/src/mesh.rs b/src/mesh.rs new file mode 100644 index 0000000..bce712f --- /dev/null +++ b/src/mesh.rs @@ -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::() 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, + pub indices: Vec, +} + +/// 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 = Vec::with_capacity(2048); + let mut indices: Vec = 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> = 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); + } +} diff --git a/src/proto.rs b/src/proto.rs new file mode 100644 index 0000000..c184d4e --- /dev/null +++ b/src/proto.rs @@ -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 }, + Players { list: Vec }, + 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, +} diff --git a/src/shader.wgsl b/src/shader.wgsl new file mode 100644 index 0000000..20a8dbd --- /dev/null +++ b/src/shader.wgsl @@ -0,0 +1,128 @@ +struct Camera { + view_proj: mat4x4, + inv_view_proj: mat4x4, + eye: vec4, + misc: vec4, +}; + +@group(0) @binding(0) var camera: Camera; + +const SUN_DIR: vec3 = vec3(0.42, 0.82, 0.39); +const SKY_HORIZON: vec3 = vec3(0.78, 0.88, 0.96); +const SKY_ZENITH: vec3 = vec3(0.30, 0.55, 0.88); +const SUN_COLOR: vec3 = vec3(1.0, 0.95, 0.85); + +fn sky_color(dir: vec3) -> vec3 { + 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, + @location(1) color: vec3, + @location(2) normal: vec3, + @location(3) leaf: f32, +}; + +struct VsOut { + @builtin(position) clip: vec4, + @location(0) world_pos: vec3, + @location(1) color: vec3, + @location(2) normal: vec3, + @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(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 { + 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(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(color, 1.0); +} + +// ---- Sky background (full-screen triangle) ---- + +struct SkyOut { + @builtin(position) clip: vec4, + @location(0) ndc: vec2, +}; + +@vertex +fn vs_sky(@builtin(vertex_index) idx: u32) -> SkyOut { + var corners = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + let p = corners[idx]; + var out: SkyOut; + out.clip = vec4(p, 1.0, 1.0); + out.ndc = p; + return out; +} + +@fragment +fn fs_sky(in: SkyOut) -> @location(0) vec4 { + let far_h = camera.inv_view_proj * vec4(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(sky_color(dir), 1.0); +} + +// ---- Outline ---- + +@vertex +fn vs_outline(@location(0) pos: vec3) -> @builtin(position) vec4 { + return camera.view_proj * vec4(pos, 1.0); +} + +@fragment +fn fs_outline() -> @location(0) vec4 { + return vec4(0.05, 0.05, 0.07, 1.0); +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..74b8f91 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,2314 @@ +use crate::camera::{Camera, InputState, KbHeld}; +use crate::mesh::{build_chunk_mesh, Vertex}; +use crate::proto::{ClientMsg, EditRec, ServerMsg}; +use crate::world::{Block, World, CHUNK_HEIGHT, CHUNK_SIZE, WORLD_RADIUS}; +use bytemuck::{Pod, Zeroable}; +use glam::{IVec3, Mat4, Vec3}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; +use wgpu::util::DeviceExt; +use winit::application::ApplicationHandler; +use winit::event::{DeviceEvent, ElementState, MouseButton, WindowEvent}; +use winit::event_loop::ActiveEventLoop; +use winit::keyboard::{KeyCode, PhysicalKey}; +use winit::window::{Window, WindowId}; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +struct CameraUniform { + view_proj: [[f32; 4]; 4], + inv_view_proj: [[f32; 4]; 4], + eye: [f32; 4], + misc: [f32; 4], +} + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +struct OutlineVertex { + pos: [f32; 3], +} + +struct ChunkBuffers { + vertex: wgpu::Buffer, + index: wgpu::Buffer, + index_count: u32, +} + +const PLAYER_HALF_W: f32 = 0.3; +const PLAYER_HEIGHT: f32 = 1.8; +const EYE_HEIGHT: f32 = 1.62; +const GRAVITY: f32 = -30.0; +const JUMP_VEL: f32 = 9.0; +const TERMINAL_VEL: f32 = -55.0; +const WALK_SPEED: f32 = 4.6; +const SPRINT_SPEED: f32 = 7.5; +const REACH: f32 = 6.0; +const OUTLINE_VERT_COUNT: u64 = 24; + +#[derive(Default, Clone)] +struct TouchBridge { + touch_mode: bool, + forward: bool, + back: bool, + left: bool, + right: bool, + jump: bool, + sprint: bool, + look_dx: f32, + look_dy: f32, + break_pressed: bool, + place_pressed: bool, + selected: Option, +} + +/// Take a Clone snapshot of the live bridge. Used so we can read the held +/// fields without holding the RefCell borrow across other code paths. +fn snapshot_bridge() -> TouchBridge { + TOUCH_BRIDGE.with(|b| b.borrow().clone()) +} + +/// Pure function: combine sticky keyboard hold state with the live touch / +/// gamepad bridge. The "release the joystick and the player stops" property +/// hinges on this being recomputed fresh every tick — see `merge_*` tests. +fn merge_held(kb: &KbHeld, br: &TouchBridge) -> KbHeld { + KbHeld { + forward: kb.forward || br.forward, + back: kb.back || br.back, + left: kb.left || br.left, + right: kb.right || br.right, + up: kb.up || br.jump, + down: kb.down, + sprint: kb.sprint || br.sprint, + } +} + +thread_local! { + static TOUCH_BRIDGE: RefCell = RefCell::new(TouchBridge::default()); + static GAME_STATUS: RefCell = RefCell::new(GameStatus { + hp: 20, + alive: true, + respawn_requested: false, + }); + static NET_BRIDGE: RefCell = RefCell::new(NetBridge::default()); + static SETTINGS: RefCell = RefCell::new(Settings::default()); +} + +#[derive(Clone, Copy)] +struct Settings { + mouse_sens: f32, + fov_deg: f32, + render_dist: f32, + paused: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + mouse_sens: 0.005, + fov_deg: 70.0, + render_dist: 240.0, + paused: false, + } + } +} + +struct GameStatus { + hp: u8, + alive: bool, + respawn_requested: bool, +} + +#[derive(Default)] +pub struct NetBridge { + pub connected: bool, + pub inbox: Vec, + pub outbox: Vec, + pub pending_name: Option, + pub my_id: Option, + pub remote_players: HashMap, +} + +#[derive(Clone, Debug)] +pub struct RemotePlayer { + pub name: String, + pub pos: Vec3, + pub yaw: f32, + pub pitch: f32, +} + +fn is_touch_mode() -> bool { + TOUCH_BRIDGE.with(|b| b.borrow().touch_mode) +} + +#[cfg(target_arch = "wasm32")] +mod wasm_api { + use super::TOUCH_BRIDGE; + use wasm_bindgen::prelude::*; + + #[wasm_bindgen] + pub fn set_touch_mode(on: bool) { + TOUCH_BRIDGE.with(|b| b.borrow_mut().touch_mode = on); + } + #[wasm_bindgen] + pub fn touch_move(forward: bool, back: bool, left: bool, right: bool) { + TOUCH_BRIDGE.with(|b| { + let mut br = b.borrow_mut(); + br.forward = forward; + br.back = back; + br.left = left; + br.right = right; + }); + } + #[wasm_bindgen] + pub fn touch_jump(on: bool) { + TOUCH_BRIDGE.with(|b| b.borrow_mut().jump = on); + } + #[wasm_bindgen] + pub fn touch_sprint(on: bool) { + TOUCH_BRIDGE.with(|b| b.borrow_mut().sprint = on); + } + #[wasm_bindgen] + pub fn touch_look(dx: f32, dy: f32) { + TOUCH_BRIDGE.with(|b| { + let mut br = b.borrow_mut(); + br.look_dx += dx; + br.look_dy += dy; + }); + } + #[wasm_bindgen] + pub fn touch_break() { + TOUCH_BRIDGE.with(|b| b.borrow_mut().break_pressed = true); + } + #[wasm_bindgen] + pub fn touch_place() { + TOUCH_BRIDGE.with(|b| b.borrow_mut().place_pressed = true); + } + #[wasm_bindgen] + pub fn select_block(b: u8) { + TOUCH_BRIDGE.with(|x| x.borrow_mut().selected = Some(b)); + } + #[wasm_bindgen] + pub fn get_hp() -> u8 { + super::GAME_STATUS.with(|s| s.borrow().hp) + } + #[wasm_bindgen] + pub fn is_alive() -> bool { + super::GAME_STATUS.with(|s| s.borrow().alive) + } + #[wasm_bindgen] + pub fn respawn() { + super::GAME_STATUS.with(|s| s.borrow_mut().respawn_requested = true); + } + #[wasm_bindgen] + pub fn set_player_name(name: String) { + super::NET_BRIDGE.with(|n| n.borrow_mut().pending_name = Some(name)); + } + #[wasm_bindgen] + pub fn on_ws_message(text: String) { + super::NET_BRIDGE.with(|n| n.borrow_mut().inbox.push(text)); + } + #[wasm_bindgen] + pub fn on_ws_open() { + super::NET_BRIDGE.with(|n| n.borrow_mut().connected = true); + } + #[wasm_bindgen] + pub fn on_ws_close() { + super::NET_BRIDGE.with(|n| { + let mut n = n.borrow_mut(); + n.connected = false; + n.my_id = None; + n.remote_players.clear(); + }); + } + #[wasm_bindgen] + pub fn drain_outbox() -> Vec { + super::NET_BRIDGE.with(|n| std::mem::take(&mut n.borrow_mut().outbox)) + } + #[wasm_bindgen] + pub fn set_paused(on: bool) { + super::SETTINGS.with(|s| s.borrow_mut().paused = on); + } + #[wasm_bindgen] + pub fn set_mouse_sens(s: f32) { + super::SETTINGS.with(|x| x.borrow_mut().mouse_sens = s.clamp(0.0005, 0.05)); + } + #[wasm_bindgen] + pub fn set_fov(deg: f32) { + super::SETTINGS.with(|x| x.borrow_mut().fov_deg = deg.clamp(40.0, 110.0)); + } + #[wasm_bindgen] + pub fn set_render_distance(blocks: f32) { + super::SETTINGS.with(|x| x.borrow_mut().render_dist = blocks.clamp(32.0, 1200.0)); + } + /// Clears all bridge input (move/look/buttons) — called on init, + /// pause, and visibility-change so we never resume with stale state. + #[wasm_bindgen] + pub fn reset_input() { + super::TOUCH_BRIDGE.with(|b| { + let mut br = b.borrow_mut(); + br.forward = false; + br.back = false; + br.left = false; + br.right = false; + br.jump = false; + br.sprint = false; + br.look_dx = 0.0; + br.look_dy = 0.0; + br.break_pressed = false; + br.place_pressed = false; + }); + } +} + +pub struct Renderer { + surface: wgpu::Surface<'static>, + device: wgpu::Device, + queue: wgpu::Queue, + config: wgpu::SurfaceConfiguration, + pipeline: wgpu::RenderPipeline, + sky_pipeline: wgpu::RenderPipeline, + outline_pipeline: wgpu::RenderPipeline, + outline_buffer: wgpu::Buffer, + depth_view: wgpu::TextureView, + camera_buffer: wgpu::Buffer, + camera_bind_group: wgpu::BindGroup, + window: Arc, + chunk_buffers: HashMap, + outline_target: Option, + visible_chunks: Vec, + remote_vb: wgpu::Buffer, + remote_ib: wgpu::Buffer, + remote_index_count: u32, +} + +const MAX_REMOTE_PLAYERS: u64 = 32; +// 2 boxes per remote player (body + head). Each box = 6 faces × 4 verts and +// 6 faces × 6 indices. +const REMOTE_VERTS_PER_PLAYER: u64 = 2 * 24; +const REMOTE_INDICES_PER_PLAYER: u64 = 2 * 36; + +impl Renderer { + pub async fn new(window: Arc) -> Self { + let size = window.inner_size(); + #[allow(unused_mut)] + let (mut width, mut height) = (size.width.max(1), size.height.max(1)); + + // On wasm winit's inner_size can be 1×1 if the canvas wasn't laid out + // before window creation. Pull the real drawingBuffer size in that case. + #[cfg(target_arch = "wasm32")] + { + if width <= 2 || height <= 2 { + if let Some(c) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.get_element_by_id("game-canvas")) + .and_then(|e| { + wasm_bindgen::JsCast::dyn_into::(e).ok() + }) + { + let w = c.width().max(1); + let h = c.height().max(1); + log::info!("overriding inner_size {}x{} with canvas {}x{}", width, height, w, h); + width = w; + height = h; + } + } + } + log::info!("initial surface size: {}x{}", width, height); + + // Pick exactly one backend on wasm. Probe WebGPU by actually asking + // for an adapter — `navigator.gpu` can exist while requestAdapter() + // still returns null (e.g. unsafe-webgpu flag off). + #[cfg(target_arch = "wasm32")] + let backends = { + let webgpu_ok = detect_webgpu().await; + if webgpu_ok { + log::info!("WebGPU adapter probe OK — using WebGPU backend"); + wgpu::Backends::BROWSER_WEBGPU + } else { + log::info!("WebGPU unavailable — using WebGL2 backend"); + wgpu::Backends::GL + } + }; + #[cfg(not(target_arch = "wasm32"))] + let backends = wgpu::Backends::PRIMARY | wgpu::Backends::GL; + + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends, + ..Default::default() + }); + + let surface = match instance.create_surface(window.clone()) { + Ok(s) => s, + Err(e) => { + log::error!("create_surface failed: {e:?}"); + #[cfg(target_arch = "wasm32")] + show_browser_compat_error(&format!("{e}")); + panic!("could not create rendering surface: {e:?}"); + } + }; + + let adapter = match instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + { + Some(a) => a, + None => { + #[cfg(target_arch = "wasm32")] + show_browser_compat_error( + "No GPU adapter available. WebGL2/WebGPU may be disabled in your browser.", + ); + panic!("no suitable GPU adapter"); + } + }; + + // Use higher limits only when the adapter actually negotiated WebGPU; + // for the WebGL2 fallback we must stay within the conservative caps. + let info = adapter.get_info(); + log::info!( + "wgpu adapter: backend={:?} type={:?} name={:?}", + info.backend, + info.device_type, + info.name, + ); + let primary_limits = if matches!(info.backend, wgpu::Backend::BrowserWebGpu) { + wgpu::Limits::default() + } else { + wgpu::Limits::downlevel_webgl2_defaults() + }; + + let device_desc = wgpu::DeviceDescriptor { + label: Some("device"), + required_features: wgpu::Features::empty(), + required_limits: primary_limits, + memory_hints: wgpu::MemoryHints::Performance, + }; + let (device, queue) = match adapter.request_device(&device_desc, None).await { + Ok(dq) => dq, + Err(e) => { + log::warn!("device request failed ({e:?}); retrying with downlevel limits"); + let fallback = wgpu::DeviceDescriptor { + label: Some("device-fallback"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_webgl2_defaults(), + memory_hints: wgpu::MemoryHints::Performance, + }; + adapter + .request_device(&fallback, None) + .await + .expect("device request failed even with downlevel limits") + } + }; + + let caps = surface.get_capabilities(&adapter); + let format = caps + .formats + .iter() + .copied() + .find(|f| f.is_srgb()) + .unwrap_or(caps.formats[0]); + + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width, + height, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&device, &config); + + let depth_view = create_depth_view(&device, width, height); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), + }); + + let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("camera"), + contents: bytemuck::bytes_of(&CameraUniform { + view_proj: Mat4::IDENTITY.to_cols_array_2d(), + inv_view_proj: Mat4::IDENTITY.to_cols_array_2d(), + eye: [0.0; 4], + misc: [0.0; 4], + }), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + let camera_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("camera bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("camera bg"), + layout: &camera_bgl, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pl"), + bind_group_layouts: &[&camera_bgl], + push_constant_ranges: &[], + }); + + let sky_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sky pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_sky"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_sky"), + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::Always, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let outline_vertex_layout = wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x3], + }; + + let outline_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("outline buf"), + size: OUTLINE_VERT_COUNT * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let remote_vb = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("remote vb"), + size: MAX_REMOTE_PLAYERS * REMOTE_VERTS_PER_PLAYER + * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let remote_ib = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("remote ib"), + size: MAX_REMOTE_PLAYERS * REMOTE_INDICES_PER_PLAYER * 4, + usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let outline_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("outline pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_outline"), + buffers: &[outline_vertex_layout], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_outline"), + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Vertex::LAYOUT], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + Self { + surface, + device, + queue, + config, + pipeline, + sky_pipeline, + outline_pipeline, + outline_buffer, + depth_view, + camera_buffer, + camera_bind_group, + window, + chunk_buffers: HashMap::new(), + outline_target: None, + visible_chunks: Vec::new(), + remote_vb, + remote_ib, + remote_index_count: 0, + } + } + + pub fn set_remote_players(&mut self, players: &[RemotePlayer]) { + if players.is_empty() { + self.remote_index_count = 0; + return; + } + let mut verts: Vec = Vec::with_capacity(players.len() * 48); + let mut indices: Vec = Vec::with_capacity(players.len() * 72); + let max = players.len().min(MAX_REMOTE_PLAYERS as usize); + for p in &players[..max] { + let h = name_hash(&p.name); + let r = 0.35 + ((h >> 0) & 0x3F) as f32 / 255.0; + let g = 0.35 + ((h >> 8) & 0x3F) as f32 / 255.0; + let b = 0.35 + ((h >> 16) & 0x3F) as f32 / 255.0; + let body = [r, g, b]; + let head = [r * 0.85 + 0.15, g * 0.85 + 0.15, b * 0.85 + 0.15]; + // Body: 0.6 × 1.3 × 0.4 (a little narrower front-to-back than side- + // to-side so the rotation is visually obvious). + emit_oriented_box( + Vec3::new(p.pos.x, p.pos.y + 0.65, p.pos.z), + Vec3::new(0.3, 0.65, 0.2), + p.yaw, + body, + &mut verts, + &mut indices, + ); + // Head: 0.5 cube, sitting on top of body. + emit_oriented_box( + Vec3::new(p.pos.x, p.pos.y + 1.55, p.pos.z), + Vec3::new(0.25, 0.25, 0.25), + p.yaw, + head, + &mut verts, + &mut indices, + ); + } + self.remote_index_count = indices.len() as u32; + self.queue + .write_buffer(&self.remote_vb, 0, bytemuck::cast_slice(&verts)); + self.queue + .write_buffer(&self.remote_ib, 0, bytemuck::cast_slice(&indices)); + } + + pub fn set_outline(&mut self, target: Option) { + if self.outline_target == target { + return; + } + self.outline_target = target; + if let Some(b) = target { + let eps = 0.002; + let min = [b.x as f32 - eps, b.y as f32 - eps, b.z as f32 - eps]; + let max = [b.x as f32 + 1.0 + eps, b.y as f32 + 1.0 + eps, b.z as f32 + 1.0 + eps]; + let c000 = [min[0], min[1], min[2]]; + let c100 = [max[0], min[1], min[2]]; + let c001 = [min[0], min[1], max[2]]; + let c101 = [max[0], min[1], max[2]]; + let c010 = [min[0], max[1], min[2]]; + let c110 = [max[0], max[1], min[2]]; + let c011 = [min[0], max[1], max[2]]; + let c111 = [max[0], max[1], max[2]]; + let verts = [ + c000, c100, c100, c101, c101, c001, c001, c000, + c010, c110, c110, c111, c111, c011, c011, c010, + c000, c010, c100, c110, c101, c111, c001, c011, + ]; + let data: Vec = verts.iter().map(|p| OutlineVertex { pos: *p }).collect(); + self.queue + .write_buffer(&self.outline_buffer, 0, bytemuck::cast_slice(&data)); + } + } + + pub fn resize(&mut self, width: u32, height: u32) { + if width == 0 || height == 0 { + return; + } + self.config.width = width; + self.config.height = height; + self.surface.configure(&self.device, &self.config); + self.depth_view = create_depth_view(&self.device, width, height); + } + + pub fn rebuild_chunk(&mut self, coord: IVec3, world: &World) { + let Some(chunk) = world.chunks.get(&coord) else { + return; + }; + let mesh = build_chunk_mesh(world, chunk); + if mesh.indices.is_empty() { + self.chunk_buffers.remove(&coord); + return; + } + let vertex = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("chunk vb"), + contents: bytemuck::cast_slice(&mesh.vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + let index = self + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("chunk ib"), + contents: bytemuck::cast_slice(&mesh.indices), + usage: wgpu::BufferUsages::INDEX, + }); + self.chunk_buffers.insert( + coord, + ChunkBuffers { + vertex, + index, + index_count: mesh.indices.len() as u32, + }, + ); + } + + pub fn upload_camera(&self, camera: &Camera, time: f32) { + let vp = camera.view_proj(); + let inv = vp.inverse(); + let uni = CameraUniform { + view_proj: vp.to_cols_array_2d(), + inv_view_proj: inv.to_cols_array_2d(), + eye: [camera.position.x, camera.position.y, camera.position.z, 1.0], + misc: [time, 0.0, 0.0, 0.0], + }; + self.queue + .write_buffer(&self.camera_buffer, 0, bytemuck::bytes_of(&uni)); + } + + pub fn set_visible(&mut self, chunks: Vec) { + self.visible_chunks = chunks; + } + + pub fn render(&self) -> Result<(), wgpu::SurfaceError> { + let frame = self.surface.get_current_texture()?; + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("enc") }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("main pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.30, + g: 0.55, + b: 0.88, + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&self.sky_pipeline); + pass.set_bind_group(0, &self.camera_bind_group, &[]); + pass.draw(0..3, 0..1); + + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.camera_bind_group, &[]); + let iter: Box> = if self.visible_chunks.is_empty() { + Box::new(self.chunk_buffers.values()) + } else { + Box::new(self.visible_chunks.iter().filter_map(|c| self.chunk_buffers.get(c))) + }; + for buf in iter { + pass.set_vertex_buffer(0, buf.vertex.slice(..)); + pass.set_index_buffer(buf.index.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..buf.index_count, 0, 0..1); + } + if self.remote_index_count > 0 { + pass.set_vertex_buffer(0, self.remote_vb.slice(..)); + pass.set_index_buffer(self.remote_ib.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..self.remote_index_count, 0, 0..1); + } + if self.outline_target.is_some() { + pass.set_pipeline(&self.outline_pipeline); + pass.set_bind_group(0, &self.camera_bind_group, &[]); + pass.set_vertex_buffer(0, self.outline_buffer.slice(..)); + pass.draw(0..OUTLINE_VERT_COUNT as u32, 0..1); + } + } + self.queue.submit(Some(encoder.finish())); + frame.present(); + Ok(()) + } +} + +fn create_depth_view(device: &wgpu::Device, w: u32, h: u32) -> wgpu::TextureView { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("depth"), + size: wgpu::Extent3d { + width: w.max(1), + height: h.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + tex.create_view(&wgpu::TextureViewDescriptor::default()) +} + +#[derive(Default)] +pub struct App { + window: Option>, + renderer: Rc>>, + world: Rc>>, + camera: Rc>>, + input: InputState, + keyboard: KbHeld, + last_frame: Option, + start_clock: Option, + pointer_locked: bool, + velocity: Vec3, + on_ground: bool, + hp: u8, + alive: bool, + max_y_since_ground: f32, + last_net_send: f32, + was_connected: bool, +} + +struct FrameClock { + #[cfg(not(target_arch = "wasm32"))] + instant: Instant, + #[cfg(target_arch = "wasm32")] + millis: f64, +} + +impl FrameClock { + fn now() -> Self { + #[cfg(not(target_arch = "wasm32"))] + { + Self { + instant: Instant::now(), + } + } + #[cfg(target_arch = "wasm32")] + { + let perf = web_sys::window().unwrap().performance().unwrap(); + Self { + millis: perf.now(), + } + } + } + + fn elapsed(&self) -> Duration { + #[cfg(not(target_arch = "wasm32"))] + { + self.instant.elapsed() + } + #[cfg(target_arch = "wasm32")] + { + let perf = web_sys::window().unwrap().performance().unwrap(); + let dt_ms = (perf.now() - self.millis).max(0.0); + Duration::from_secs_f64(dt_ms / 1000.0) + } + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + + #[allow(unused_mut)] + let mut attrs = Window::default_attributes().with_title("Voxel Game"); + + #[cfg(target_arch = "wasm32")] + { + use winit::platform::web::WindowAttributesExtWebSys; + let canvas = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.get_element_by_id("game-canvas")) + .and_then(|e| e.dyn_into::().ok()); + if let Some(c) = canvas { + // Force the drawingBuffer to match the laid-out CSS size × DPR + // before handing the canvas to winit. Without this, winit can + // hand wgpu a 1×1 canvas which renders as a single stretched + // colored pixel on WebGPU. + let dpr = web_sys::window() + .map(|w| w.device_pixel_ratio()) + .unwrap_or(1.0) + .max(1.0); + let rect = c.get_bounding_client_rect(); + let css_w = if rect.width() > 0.0 { + rect.width() + } else { + web_sys::window() + .and_then(|w| w.inner_width().ok()) + .and_then(|v| v.as_f64()) + .unwrap_or(1280.0) + }; + let css_h = if rect.height() > 0.0 { + rect.height() + } else { + web_sys::window() + .and_then(|w| w.inner_height().ok()) + .and_then(|v| v.as_f64()) + .unwrap_or(720.0) + }; + let pw = (css_w * dpr).max(1.0) as u32; + let ph = (css_h * dpr).max(1.0) as u32; + c.set_width(pw); + c.set_height(ph); + log::info!("canvas sized to {}x{} (css {}x{} dpr {})", pw, ph, css_w, css_h, dpr); + attrs = attrs.with_canvas(Some(c)); + } + } + + let window = Arc::new(event_loop.create_window(attrs).unwrap()); + + #[cfg(target_arch = "wasm32")] + { + if let Some(win) = web_sys::window() { + let dpr = win.device_pixel_ratio().max(1.0); + let w = win.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(1280.0); + let h = win.inner_height().ok().and_then(|v| v.as_f64()).unwrap_or(720.0); + let _ = window.request_inner_size(winit::dpi::PhysicalSize::new( + (w * dpr) as u32, + (h * dpr) as u32, + )); + } + } + + self.window = Some(window.clone()); + + // World + camera + initial meshes + let world = World::new(); + let aspect = { + let s = window.inner_size(); + s.width as f32 / s.height.max(1) as f32 + }; + let spawn = find_safe_spawn(&world); + let mut camera = Camera::new(aspect); + camera.position = Vec3::new(spawn.x, spawn.y + EYE_HEIGHT, spawn.z); + self.max_y_since_ground = spawn.y; + *self.camera.borrow_mut() = Some(camera); + *self.world.borrow_mut() = Some(world); + + let renderer_slot = self.renderer.clone(); + let world_slot = self.world.clone(); + let window_for_async = window.clone(); + + let init = async move { + let mut renderer = Renderer::new(window_for_async).await; + if let Some(w) = world_slot.borrow().as_ref() { + let coords: Vec = w.chunks.keys().copied().collect(); + for c in coords { + renderer.rebuild_chunk(c, w); + } + } + *renderer_slot.borrow_mut() = Some(renderer); + }; + + #[cfg(not(target_arch = "wasm32"))] + { + pollster::block_on(init); + } + #[cfg(target_arch = "wasm32")] + { + wasm_bindgen_futures::spawn_local(init); + } + + self.last_frame = Some(FrameClock::now()); + self.start_clock = Some(FrameClock::now()); + self.input.selected_block = Block::Stone as u8; + self.hp = 20; + self.alive = true; + self.push_status(); + window.request_redraw(); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + if let Some(r) = self.renderer.borrow_mut().as_mut() { + r.resize(size.width, size.height); + } + if let Some(cam) = self.camera.borrow_mut().as_mut() { + cam.aspect = size.width as f32 / size.height.max(1) as f32; + } + } + WindowEvent::KeyboardInput { event, .. } => { + let pressed = event.state == ElementState::Pressed; + if let PhysicalKey::Code(code) = event.physical_key { + match code { + KeyCode::KeyW => self.keyboard.forward = pressed, + KeyCode::KeyS => self.keyboard.back = pressed, + KeyCode::KeyA => self.keyboard.left = pressed, + KeyCode::KeyD => self.keyboard.right = pressed, + KeyCode::Space => self.keyboard.up = pressed, + KeyCode::ShiftLeft | KeyCode::ShiftRight => { + self.keyboard.down = pressed + } + KeyCode::ControlLeft | KeyCode::ControlRight => { + self.keyboard.sprint = pressed; + } + KeyCode::Digit1 if pressed => self.input.selected_block = Block::Grass as u8, + KeyCode::Digit2 if pressed => self.input.selected_block = Block::Dirt as u8, + KeyCode::Digit3 if pressed => self.input.selected_block = Block::Stone as u8, + KeyCode::Digit4 if pressed => self.input.selected_block = Block::Sand as u8, + KeyCode::Digit5 if pressed => self.input.selected_block = Block::Wood as u8, + KeyCode::Digit6 if pressed => { + self.input.selected_block = Block::Leaves as u8 + } + KeyCode::Digit7 if pressed => { + self.input.selected_block = Block::Cobble as u8 + } + KeyCode::Digit8 if pressed => { + self.input.selected_block = Block::Brick as u8 + } + KeyCode::Digit9 if pressed => { + self.input.selected_block = Block::Snow as u8 + } + KeyCode::Digit0 if pressed => { + self.input.selected_block = Block::Ice as u8 + } + _ => {} + } + } + } + WindowEvent::MouseInput { state, button, .. } => { + if state == ElementState::Pressed { + match button { + MouseButton::Left => self.input.primary_clicked = true, + MouseButton::Right => self.input.secondary_clicked = true, + _ => {} + } + if !is_touch_mode() { + if let Some(w) = &self.window { + let _ = w.set_cursor_grab(winit::window::CursorGrabMode::Locked); + w.set_cursor_visible(false); + } + #[cfg(target_arch = "wasm32")] + { + if let Some(canvas) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.get_element_by_id("game-canvas")) + { + let _ = canvas.request_pointer_lock(); + } + } + self.pointer_locked = true; + } + } + } + WindowEvent::RedrawRequested => { + self.tick(); + if let Some(w) = &self.window { + w.request_redraw(); + } + } + _ => {} + } + } + + fn device_event( + &mut self, + _event_loop: &ActiveEventLoop, + _device_id: winit::event::DeviceId, + event: DeviceEvent, + ) { + if let DeviceEvent::MouseMotion { delta } = event { + if self.pointer_locked { + self.input.mouse_dx += delta.0 as f32; + self.input.mouse_dy += delta.1 as f32; + } + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if let Some(w) = &self.window { + w.request_redraw(); + } + } +} + +impl App { + fn push_status(&self) { + GAME_STATUS.with(|s| { + let mut s = s.borrow_mut(); + s.hp = self.hp; + s.alive = self.alive; + }); + } + + fn take_damage(&mut self, d: u8) { + if !self.alive { + return; + } + self.hp = self.hp.saturating_sub(d); + if self.hp == 0 { + self.alive = false; + } + self.push_status(); + } + + fn do_respawn(&mut self) { + let feet = { + let world_borrow = self.world.borrow(); + match world_borrow.as_ref() { + Some(w) => find_safe_spawn(w), + None => Vec3::new(0.5, 60.0, 0.5), + } + }; + if let Some(cam) = self.camera.borrow_mut().as_mut() { + cam.position = Vec3::new(feet.x, feet.y + EYE_HEIGHT, feet.z); + } + self.velocity = Vec3::ZERO; + self.on_ground = false; + self.max_y_since_ground = feet.y; + self.hp = 20; + self.alive = true; + self.push_status(); + } + + fn drain_net_inbox(&mut self) { + let inbox: Vec = + NET_BRIDGE.with(|n| std::mem::take(&mut n.borrow_mut().inbox)); + if inbox.is_empty() { + return; + } + let mut world_borrow = self.world.borrow_mut(); + let Some(world) = world_borrow.as_mut() else { + return; + }; + let mut dirty: std::collections::HashSet = std::collections::HashSet::new(); + for line in inbox { + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + match msg { + ServerMsg::Welcome { id, edits } => { + NET_BRIDGE.with(|n| n.borrow_mut().my_id = Some(id)); + for e in edits { + if apply_edit(world, &e) { + for c in chunks_for_edit(IVec3::new(e.x, e.y, e.z)) { + dirty.insert(c); + } + } + } + } + ServerMsg::Players { list } => { + NET_BRIDGE.with(|n| { + let mut n = n.borrow_mut(); + let my = n.my_id; + n.remote_players.clear(); + for p in list { + if Some(p.id) == my { + continue; + } + n.remote_players.insert( + p.id, + RemotePlayer { + name: p.name, + pos: Vec3::new(p.x, p.y, p.z), + yaw: p.yaw, + pitch: p.pitch, + }, + ); + } + }); + } + ServerMsg::Edit { x, y, z, block } => { + let rec = EditRec { x, y, z, block }; + if apply_edit(world, &rec) { + for c in chunks_for_edit(IVec3::new(x, y, z)) { + dirty.insert(c); + } + } + } + ServerMsg::Leave { id } => { + NET_BRIDGE.with(|n| { + n.borrow_mut().remote_players.remove(&id); + }); + } + } + } + if !dirty.is_empty() { + if let Some(r) = self.renderer.borrow_mut().as_mut() { + for c in dirty { + r.rebuild_chunk(c, world); + if let Some(ch) = world.chunks.get_mut(&c) { + ch.dirty = false; + } + } + } + } + } + + fn tick(&mut self) { + let dt = match self.last_frame.as_ref() { + Some(c) => c.elapsed().as_secs_f32().min(0.1), + None => 0.016, + }; + self.last_frame = Some(FrameClock::now()); + let time = self + .start_clock + .as_ref() + .map(|c| c.elapsed().as_secs_f32()) + .unwrap_or(0.0); + + let settings = SETTINGS.with(|s| *s.borrow()); + + // While paused (menu open), clear inputs so player doesn't drift, and skip physics. + if settings.paused { + self.keyboard = KbHeld::default(); + self.input.mouse_dx = 0.0; + self.input.mouse_dy = 0.0; + self.input.primary_clicked = false; + self.input.secondary_clicked = false; + TOUCH_BRIDGE.with(|b| { + let mut br = b.borrow_mut(); + br.forward = false; + br.back = false; + br.left = false; + br.right = false; + br.jump = false; + br.sprint = false; + br.look_dx = 0.0; + br.look_dy = 0.0; + br.break_pressed = false; + br.place_pressed = false; + }); + self.drain_net_inbox(); + let camera_borrow = self.camera.borrow(); + if let Some(camera) = camera_borrow.as_ref() { + let world_borrow = self.world.borrow(); + let visible = if let Some(w) = world_borrow.as_ref() { + compute_visible_chunks(w, camera, settings.render_dist) + } else { + Vec::new() + }; + let remotes: Vec = NET_BRIDGE.with(|n| { + n.borrow().remote_players.values().cloned().collect() + }); + if let Some(r) = self.renderer.borrow_mut().as_mut() { + r.set_outline(None); + r.set_visible(visible); + r.set_remote_players(&remotes); + r.upload_camera(camera, time); + let _ = r.render(); + } + } + return; + } + + // Drain the bridge into the per-tick one-shots; the *held* directional + // state is merged into locals further down — never written back into + // a persistent field, which would re-introduce the sticky-input bug. + TOUCH_BRIDGE.with(|b| { + let mut br = b.borrow_mut(); + self.input.mouse_dx += br.look_dx; + self.input.mouse_dy += br.look_dy; + br.look_dx = 0.0; + br.look_dy = 0.0; + if br.break_pressed { + self.input.primary_clicked = true; + br.break_pressed = false; + } + if br.place_pressed { + self.input.secondary_clicked = true; + br.place_pressed = false; + } + if let Some(sel) = br.selected.take() { + self.input.selected_block = sel; + } + }); + + // Honor respawn request. + let respawn = GAME_STATUS.with(|s| { + let mut s = s.borrow_mut(); + let r = s.respawn_requested; + s.respawn_requested = false; + r + }); + if respawn { + self.do_respawn(); + } + + // Pull network messages (edits, player list). + self.drain_net_inbox(); + + // On (re)connect, send Hello + first state. + let connected = NET_BRIDGE.with(|n| n.borrow().connected); + if connected && !self.was_connected { + let name = NET_BRIDGE + .with(|n| n.borrow_mut().pending_name.take()) + .unwrap_or_else(|| format!("guest-{}", (time * 1000.0) as u32 % 10000)); + let msg = ClientMsg::Hello { name }; + if let Ok(s) = serde_json::to_string(&msg) { + NET_BRIDGE.with(|n| n.borrow_mut().outbox.push(s)); + } + } + self.was_connected = connected; + + let (mx, my) = self.input.consume_mouse(); + + let mut camera_borrow = self.camera.borrow_mut(); + let Some(camera) = camera_borrow.as_mut() else { + return; + }; + + camera.yaw += mx * settings.mouse_sens; + camera.pitch -= my * settings.mouse_sens; + let limit = std::f32::consts::FRAC_PI_2 - 0.01; + camera.pitch = camera.pitch.clamp(-limit, limit); + camera.fovy = settings.fov_deg.to_radians(); + + let mut world_borrow = self.world.borrow_mut(); + let Some(world) = world_borrow.as_mut() else { + return; + }; + + let mut pending_damage: u8 = 0; + if self.alive { + // Merge sticky keyboard + bridge into a tick-local snapshot. Pure + // function so the bug class "release leaves the player walking" + // can be regression-tested without spinning up a real World. + let held = merge_held(&self.keyboard, &snapshot_bridge()); + + let mut wish = Vec3::ZERO; + if held.forward { + wish += camera.forward_flat(); + } + if held.back { + wish -= camera.forward_flat(); + } + if held.right { + wish += camera.right_flat(); + } + if held.left { + wish -= camera.right_flat(); + } + let wish = wish.normalize_or_zero(); + let h_speed = if held.sprint { SPRINT_SPEED } else { WALK_SPEED }; + self.velocity.x = wish.x * h_speed; + self.velocity.z = wish.z * h_speed; + + if held.up && self.on_ground { + self.velocity.y = JUMP_VEL; + self.on_ground = false; + } + + self.velocity.y += GRAVITY * dt; + if self.velocity.y < TERMINAL_VEL { + self.velocity.y = TERMINAL_VEL; + } + + let was_on_ground = self.on_ground; + let mut feet = Vec3::new( + camera.position.x, + camera.position.y - EYE_HEIGHT, + camera.position.z, + ); + let delta = self.velocity * dt; + move_axis(world, &mut feet, delta.x, Axis::X); + move_axis(world, &mut feet, delta.z, Axis::Z); + let y_hit = move_axis(world, &mut feet, delta.y, Axis::Y); + if y_hit { + if delta.y < 0.0 { + self.on_ground = true; + } + self.velocity.y = 0.0; + } else if delta.y != 0.0 { + self.on_ground = false; + } + + // Fall damage bookkeeping. + if was_on_ground && !self.on_ground { + self.max_y_since_ground = feet.y; + } + if !self.on_ground { + self.max_y_since_ground = self.max_y_since_ground.max(feet.y); + } + if !was_on_ground && self.on_ground { + let dist = (self.max_y_since_ground - feet.y).max(0.0); + pending_damage = pending_damage.saturating_add(fall_damage(dist)); + } + + // Void death. + if feet.y < -25.0 { + pending_damage = 20; + } + + camera.position = Vec3::new(feet.x, feet.y + EYE_HEIGHT, feet.z); + + // Target + block interactions. + let hit = world.raycast(camera.position, camera.forward(), REACH); + let primary = std::mem::replace(&mut self.input.primary_clicked, false); + let secondary = std::mem::replace(&mut self.input.secondary_clicked, false); + + let mut edit_to_broadcast: Option = None; + if let Some((hit_pos, prev_pos)) = hit { + if primary || secondary { + let (target, set_to) = if primary { + (hit_pos, Block::Air) + } else { + let block = block_from_u8(self.input.selected_block); + (prev_pos, block) + }; + let blocks_player = set_to.solid() && { + let bb = AabbI::block(target); + aabb_overlap_player(bb, feet) + }; + if !blocks_player && world.set_block(target, set_to) { + let dirty: Vec = world + .chunks + .iter() + .filter(|(_, c)| c.dirty) + .map(|(k, _)| *k) + .collect(); + if let Some(r) = self.renderer.borrow_mut().as_mut() { + for coord in dirty { + r.rebuild_chunk(coord, world); + if let Some(c) = world.chunks.get_mut(&coord) { + c.dirty = false; + } + } + } + edit_to_broadcast = Some(EditRec { + x: target.x, + y: target.y, + z: target.z, + block: set_to as u8, + }); + } + } + } + if let Some(e) = edit_to_broadcast { + let msg = ClientMsg::Edit { + x: e.x, + y: e.y, + z: e.z, + block: e.block, + }; + if let Ok(s) = serde_json::to_string(&msg) { + NET_BRIDGE.with(|n| n.borrow_mut().outbox.push(s)); + } + } + let _ = hit; + } else { + // Dead — drain inputs without acting on them. + self.input.primary_clicked = false; + self.input.secondary_clicked = false; + } + + // Periodic state broadcast. + if connected && self.alive && time - self.last_net_send > 0.1 { + self.last_net_send = time; + let msg = ClientMsg::State { + x: camera.position.x, + y: camera.position.y - EYE_HEIGHT, + z: camera.position.z, + yaw: camera.yaw, + pitch: camera.pitch, + }; + if let Ok(s) = serde_json::to_string(&msg) { + NET_BRIDGE.with(|n| n.borrow_mut().outbox.push(s)); + } + } + + // Compute outline target now (with mutable borrow already done above we re-raycast cheaply). + let outline_target = if self.alive { + world + .raycast(camera.position, camera.forward(), REACH) + .map(|(h, _)| h) + } else { + None + }; + + drop(world_borrow); + drop(camera_borrow); + + if pending_damage > 0 { + self.take_damage(pending_damage); + } + + let camera_borrow = self.camera.borrow(); + let camera = camera_borrow.as_ref().unwrap(); + let world_borrow = self.world.borrow(); + let world_ref = world_borrow.as_ref(); + let visible = if let Some(w) = world_ref { + compute_visible_chunks(w, camera, settings.render_dist) + } else { + Vec::new() + }; + let remotes: Vec = NET_BRIDGE.with(|n| { + n.borrow() + .remote_players + .values() + .cloned() + .collect() + }); + if let Some(r) = self.renderer.borrow_mut().as_mut() { + r.set_outline(outline_target); + r.set_visible(visible); + r.set_remote_players(&remotes); + r.upload_camera(camera, time); + match r.render() { + Ok(()) => {} + Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + let size = r.window.inner_size(); + r.resize(size.width, size.height); + } + Err(e) => log::error!("render error: {e:?}"), + } + } + let _ = WORLD_RADIUS; + } +} + +fn block_from_u8(b: u8) -> Block { + match b { + x if x == Block::Grass as u8 => Block::Grass, + x if x == Block::Dirt as u8 => Block::Dirt, + x if x == Block::Stone as u8 => Block::Stone, + x if x == Block::Sand as u8 => Block::Sand, + x if x == Block::Wood as u8 => Block::Wood, + x if x == Block::Leaves as u8 => Block::Leaves, + x if x == Block::Cobble as u8 => Block::Cobble, + x if x == Block::Brick as u8 => Block::Brick, + x if x == Block::Snow as u8 => Block::Snow, + x if x == Block::Ice as u8 => Block::Ice, + _ => Block::Stone, + } +} + +fn apply_edit(world: &mut World, e: &EditRec) -> bool { + let block = if e.block == 0 { + Block::Air + } else { + block_from_u8(e.block) + }; + world.set_block(IVec3::new(e.x, e.y, e.z), block) +} + +fn chunks_for_edit(p: IVec3) -> Vec { + use crate::world::Face; + let (c, _) = World::block_to_chunk(p); + let mut out = vec![c]; + for face in Face::ALL { + let n = p + face.normal(); + let (nc, _) = World::block_to_chunk(n); + if nc != c && !out.contains(&nc) { + out.push(nc); + } + } + out +} + +#[cfg(target_arch = "wasm32")] +async fn detect_webgpu() -> bool { + use wasm_bindgen::{JsCast, JsValue}; + let Some(win) = web_sys::window() else { + log::info!("detect_webgpu: no window"); + return false; + }; + let nav: JsValue = win.navigator().into(); + let gpu = match js_sys::Reflect::get(&nav, &JsValue::from_str("gpu")) { + Ok(v) if !v.is_undefined() && !v.is_null() => v, + _ => { + log::info!("detect_webgpu: navigator.gpu missing"); + return false; + } + }; + let req = match js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter")) { + Ok(v) => v, + Err(_) => { + log::info!("detect_webgpu: requestAdapter prop missing"); + return false; + } + }; + let req_fn: js_sys::Function = match req.dyn_into() { + Ok(f) => f, + Err(_) => { + log::info!("detect_webgpu: requestAdapter not callable"); + return false; + } + }; + let promise = match req_fn.call0(&gpu) { + Ok(p) => p, + Err(e) => { + log::info!("detect_webgpu: requestAdapter threw: {e:?}"); + return false; + } + }; + let promise: js_sys::Promise = match promise.dyn_into() { + Ok(p) => p, + Err(_) => { + log::info!("detect_webgpu: requestAdapter did not return Promise"); + return false; + } + }; + match wasm_bindgen_futures::JsFuture::from(promise).await { + Ok(adapter) => { + let ok = !adapter.is_null() && !adapter.is_undefined(); + log::info!("detect_webgpu: requestAdapter resolved (adapter present = {ok})"); + ok + } + Err(e) => { + log::info!("detect_webgpu: requestAdapter rejected: {e:?}"); + false + } + } +} + +#[cfg(target_arch = "wasm32")] +fn show_browser_compat_error(detail: &str) { + if let Some(doc) = web_sys::window().and_then(|w| w.document()) { + if let Some(body) = doc.body() { + let html = format!( + "
\ +

Couldn't start the renderer

\ +

Your browser couldn't open a WebGL2 or WebGPU context on the canvas.

\ +

Chrome / Chromium / Edge: open \ + chrome://gpu and confirm \"WebGL 2\" says \ + Hardware accelerated. If not, enable hardware acceleration in \ + chrome://settings/system, or set \ + chrome://flags/#ignore-gpu-blocklist to Enabled.\ +

\ +

LibreWolf / Firefox: in about:config set \ + webgl.disabled = false, \ + webgl.force-enabled = true, \ + privacy.resistFingerprinting = false.\ +

\ +

Underlying error: {detail}

\ +
", + detail = html_escape(detail) + ); + let _ = body.insert_adjacent_html("beforeend", &html); + } + } +} + +#[cfg(target_arch = "wasm32")] +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// Returns the player feet position to spawn at. Anchored to the *natural* +/// terrain height computed from the same noise the generator uses, so player +/// edits at spawn (towers, holes) don't permanently move the spawn point. +/// Only scans upward from the natural surface if a tower currently blocks it, +/// in which case we land the player on top of that tower — and as soon as +/// it's broken, the spawn drops back to the natural floor. +/// Pure: damage taken from a free-fall of `distance` blocks. The first +/// 3.5 blocks are safe (jump height + slack); each block beyond costs one +/// HP, capped at 20. Extracted so the boundary cases can be unit-tested. +pub fn fall_damage(distance: f32) -> u8 { + if !distance.is_finite() || distance <= 3.5 { + 0 + } else { + (distance - 3.5).floor().clamp(0.0, 20.0) as u8 + } +} + +fn find_safe_spawn(world: &World) -> Vec3 { + use crate::world::natural_surface_y; + let (x, z) = (0_i32, 0_i32); + let surface_y = natural_surface_y(x, z); // topmost solid in original terrain + let mut feet_y = surface_y + 1; // first air block above + let max_y = CHUNK_HEIGHT - 2; + while feet_y < max_y { + let body_blocked = world + .get_block(IVec3::new(x, feet_y, z)) + .solid() + || world + .get_block(IVec3::new(x, feet_y + 1, z)) + .solid(); + if !body_blocked { + return Vec3::new(x as f32 + 0.5, feet_y as f32, z as f32 + 0.5); + } + feet_y += 1; + } + Vec3::new(x as f32 + 0.5, (surface_y + 1) as f32, z as f32 + 0.5) +} + +fn name_hash(s: &str) -> u32 { + let mut h: u32 = 2166136261; + for b in s.bytes() { + h ^= b as u32; + h = h.wrapping_mul(16777619); + } + h +} + +/// Emit 6 faces of a box centered at `center`, rotated around Y by `yaw`, +/// extending `half_extents` in each local-space axis. Each face still winds +/// CCW outward (verified by `oriented_box_winds_correctly` test). +pub fn emit_oriented_box( + center: Vec3, + half_extents: Vec3, + yaw: f32, + color: [f32; 3], + verts: &mut Vec, + indices: &mut Vec, +) { + let cos_y = yaw.cos(); + let sin_y = yaw.sin(); + let rotate_xz = |x: f32, z: f32| (x * cos_y - z * sin_y, x * sin_y + z * cos_y); + let world_pt = |lx: f32, ly: f32, lz: f32| { + let (rx, rz) = rotate_xz(lx, lz); + [center.x + rx, center.y + ly, center.z + rz] + }; + let world_normal = |nx: f32, ny: f32, nz: f32| { + let (rx, rz) = rotate_xz(nx, nz); + [rx, ny, rz] + }; + + let (hx, hy, hz) = (half_extents.x, half_extents.y, half_extents.z); + // Faces in local coords; each face's four corners traverse CCW when + // viewed from outside, matching the [0,2,1,0,3,2] winding below. + let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ + // +X + ([1.0, 0.0, 0.0], [ + [ hx, -hy, -hz], + [ hx, -hy, hz], + [ hx, hy, hz], + [ hx, hy, -hz], + ]), + // -X + ([-1.0, 0.0, 0.0], [ + [-hx, -hy, hz], + [-hx, -hy, -hz], + [-hx, hy, -hz], + [-hx, hy, hz], + ]), + // +Y + ([0.0, 1.0, 0.0], [ + [-hx, hy, -hz], + [ hx, hy, -hz], + [ hx, hy, hz], + [-hx, hy, hz], + ]), + // -Y + ([0.0, -1.0, 0.0], [ + [-hx, -hy, hz], + [ hx, -hy, hz], + [ hx, -hy, -hz], + [-hx, -hy, -hz], + ]), + // +Z + ([0.0, 0.0, 1.0], [ + [ hx, -hy, hz], + [-hx, -hy, hz], + [-hx, hy, hz], + [ hx, hy, hz], + ]), + // -Z + ([0.0, 0.0, -1.0], [ + [-hx, -hy, -hz], + [ hx, -hy, -hz], + [ hx, hy, -hz], + [-hx, hy, -hz], + ]), + ]; + for (n_local, corners_local) in faces { + let n_world = world_normal(n_local[0], n_local[1], n_local[2]); + let base = verts.len() as u32; + for c in corners_local { + verts.push(Vertex { + pos: world_pt(c[0], c[1], c[2]), + color, + normal: n_world, + leaf: 0.0, + }); + } + indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]); + } +} + +fn compute_visible_chunks(world: &World, camera: &Camera, render_dist: f32) -> Vec { + let vp = camera.view_proj(); + let m = vp.to_cols_array_2d(); + let row = |i: usize| [m[0][i], m[1][i], m[2][i], m[3][i]]; + let r0 = row(0); + let r1 = row(1); + let r2 = row(2); + let r3 = row(3); + let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]]; + let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]]; + let planes: [[f32; 4]; 6] = [ + add(r3, r0), + sub(r3, r0), + add(r3, r1), + sub(r3, r1), + add(r3, r2), + sub(r3, r2), + ]; + let dist2 = render_dist * render_dist; + let cam_xz = (camera.position.x, camera.position.z); + let mut out = Vec::with_capacity(world.chunks.len()); + for coord in world.chunks.keys() { + // Cheap radial cull first (square distance from camera to chunk centre on XZ). + let cx = (coord.x * CHUNK_SIZE + CHUNK_SIZE / 2) as f32; + let cz = (coord.z * CHUNK_SIZE + CHUNK_SIZE / 2) as f32; + let dx = cx - cam_xz.0; + let dz = cz - cam_xz.1; + if dx * dx + dz * dz > dist2 { + continue; + } + let min = Vec3::new( + (coord.x * CHUNK_SIZE) as f32, + 0.0, + (coord.z * CHUNK_SIZE) as f32, + ); + let max = min + Vec3::new(CHUNK_SIZE as f32, CHUNK_HEIGHT as f32, CHUNK_SIZE as f32); + let mut inside = true; + for p in &planes { + let px = if p[0] > 0.0 { max.x } else { min.x }; + let py = if p[1] > 0.0 { max.y } else { min.y }; + let pz = if p[2] > 0.0 { max.z } else { min.z }; + if p[0] * px + p[1] * py + p[2] * pz + p[3] < 0.0 { + inside = false; + break; + } + } + if inside { + out.push(*coord); + } + } + out +} + +#[derive(Copy, Clone)] +enum Axis { + X, + Y, + Z, +} + +struct AabbI { + min: Vec3, + max: Vec3, +} + +impl AabbI { + fn block(p: IVec3) -> Self { + Self { + min: Vec3::new(p.x as f32, p.y as f32, p.z as f32), + max: Vec3::new(p.x as f32 + 1.0, p.y as f32 + 1.0, p.z as f32 + 1.0), + } + } +} + +fn aabb_overlap_player(b: AabbI, feet: Vec3) -> bool { + let p_min = Vec3::new(feet.x - PLAYER_HALF_W, feet.y, feet.z - PLAYER_HALF_W); + let p_max = Vec3::new( + feet.x + PLAYER_HALF_W, + feet.y + PLAYER_HEIGHT, + feet.z + PLAYER_HALF_W, + ); + p_min.x < b.max.x + && p_max.x > b.min.x + && p_min.y < b.max.y + && p_max.y > b.min.y + && p_min.z < b.max.z + && p_max.z > b.min.z +} + +fn player_overlaps_solid(world: &World, feet: Vec3) -> bool { + let eps = 0.0; + let min_x = (feet.x - PLAYER_HALF_W + eps).floor() as i32; + let max_x = (feet.x + PLAYER_HALF_W - eps).floor() as i32; + let min_y = feet.y.floor() as i32; + let max_y = (feet.y + PLAYER_HEIGHT - 0.001).floor() as i32; + let min_z = (feet.z - PLAYER_HALF_W + eps).floor() as i32; + let max_z = (feet.z + PLAYER_HALF_W - eps).floor() as i32; + for x in min_x..=max_x { + for y in min_y..=max_y { + for z in min_z..=max_z { + if world.get_block(IVec3::new(x, y, z)).solid() { + return true; + } + } + } + } + false +} + +/// Sweep the player AABB along `axis` by `delta`, snapping against the first +/// solid face encountered. Sub-steps are capped below one block so the +/// single-face snap is always correct — the previous one-shot snap could +/// place the player *inside* terrain when per-tick delta exceeded one block +/// (e.g. high terminal-velocity falls). +fn move_axis(world: &World, feet: &mut Vec3, delta: f32, axis: Axis) -> bool { + if delta == 0.0 { + return false; + } + const MAX_STEP: f32 = 0.45; + let n = (delta.abs() / MAX_STEP).ceil().max(1.0) as i32; + let step = delta / n as f32; + let eps = 0.001; + for _ in 0..n { + let candidate = match axis { + Axis::X => Vec3::new(feet.x + step, feet.y, feet.z), + Axis::Y => Vec3::new(feet.x, feet.y + step, feet.z), + Axis::Z => Vec3::new(feet.x, feet.y, feet.z + step), + }; + if !player_overlaps_solid(world, candidate) { + *feet = candidate; + continue; + } + match axis { + Axis::X => { + if step > 0.0 { + let edge = candidate.x + PLAYER_HALF_W; + feet.x = edge.floor() - PLAYER_HALF_W - eps; + } else { + let edge = candidate.x - PLAYER_HALF_W; + feet.x = edge.floor() + 1.0 + PLAYER_HALF_W + eps; + } + } + Axis::Z => { + if step > 0.0 { + let edge = candidate.z + PLAYER_HALF_W; + feet.z = edge.floor() - PLAYER_HALF_W - eps; + } else { + let edge = candidate.z - PLAYER_HALF_W; + feet.z = edge.floor() + 1.0 + PLAYER_HALF_W + eps; + } + } + Axis::Y => { + if step > 0.0 { + let top = candidate.y + PLAYER_HEIGHT; + feet.y = top.floor() - PLAYER_HEIGHT - eps; + } else { + let bottom = candidate.y; + feet.y = bottom.floor() + 1.0 + eps; + } + } + } + return true; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::world::{natural_surface_y, Block, World}; + use glam::IVec3; + + // ---------- fall_damage: pure boundary tests ---------- + + // ---------- block_from_u8: every hotbar slot must round-trip ---------- + + #[test] + fn block_from_u8_roundtrips_for_every_hotbar_slot() { + // The HTML hotbar exposes data-b values 1..=10. Each must produce a + // solid Block when fed through select_block → tick → block_from_u8. + use crate::world::Block; + let expected: &[(u8, Block)] = &[ + (1, Block::Grass), + (2, Block::Dirt), + (3, Block::Stone), + (4, Block::Sand), + (5, Block::Wood), + (6, Block::Leaves), + (7, Block::Cobble), + (8, Block::Brick), + (9, Block::Snow), + (10, Block::Ice), + ]; + for &(u, b) in expected { + assert_eq!(block_from_u8(u), b, "slot {} must map to {:?}", u, b); + assert!(b.solid(), "{:?} should be a solid placeable block", b); + } + } + + #[test] + fn block_from_u8_falls_back_to_stone_on_garbage() { + use crate::world::Block; + assert_eq!(block_from_u8(99), Block::Stone); + assert_eq!(block_from_u8(255), Block::Stone); + } + + #[test] + fn fall_damage_zero_for_short_falls() { + assert_eq!(fall_damage(0.0), 0); + assert_eq!(fall_damage(2.5), 0); + assert_eq!(fall_damage(3.5), 0); // exactly the threshold + } + + #[test] + fn fall_damage_starts_at_one_just_past_threshold() { + assert_eq!(fall_damage(4.5), 1); + assert_eq!(fall_damage(5.0), 1); + } + + #[test] + fn fall_damage_caps_at_20() { + assert_eq!(fall_damage(100.0), 20); + assert_eq!(fall_damage(f32::MAX), 20); + } + + #[test] + fn fall_damage_handles_nonsense() { + // Negative distances, NaN, and infinity all collapse to "no damage" + // — safer than guessing what an impossible fall should cost. + assert_eq!(fall_damage(-5.0), 0); + assert_eq!(fall_damage(f32::NAN), 0); + assert_eq!(fall_damage(f32::INFINITY), 0); + assert_eq!(fall_damage(f32::NEG_INFINITY), 0); + } + + // ---------- find_safe_spawn: prevents the death-loop bug ---------- + + #[test] + fn spawn_lands_on_natural_surface_in_pristine_world() { + let world = World::new(); + let spawn = find_safe_spawn(&world); + let expected = natural_surface_y(0, 0) + 1; + assert_eq!(spawn.y as i32, expected); + } + + #[test] + fn spawn_rises_above_player_built_tower() { + let mut world = World::new(); + let surface = natural_surface_y(0, 0); + for y in (surface + 1)..=(surface + 10) { + assert!( + world.set_block(IVec3::new(0, y, 0), Block::Stone), + "tower set must succeed" + ); + } + let spawn = find_safe_spawn(&world); + assert!( + spawn.y as i32 > surface + 10, + "spawn must be above the tower top; got {} surface {}", + spawn.y, + surface + ); + } + + #[test] + fn spawn_returns_to_natural_after_tower_is_broken() { + // Reproduces the user-reported bug: build at spawn, break it, + // spawn must drop back to the natural floor (not stay perched). + let mut world = World::new(); + let surface = natural_surface_y(0, 0); + for y in (surface + 1)..=(surface + 10) { + world.set_block(IVec3::new(0, y, 0), Block::Stone); + } + for y in (surface + 1)..=(surface + 10) { + world.set_block(IVec3::new(0, y, 0), Block::Air); + } + let spawn = find_safe_spawn(&world); + let expected = surface + 1; + assert_eq!( + spawn.y as i32, expected, + "after breaking the tower spawn should return to natural surface" + ); + } + + #[test] + fn spawn_unaffected_by_remote_holes_below_surface() { + // Even if someone digs into the original ground, the *spawn anchor* + // is the natural surface, so we still try there first. + let mut world = World::new(); + let surface = natural_surface_y(0, 0); + for y in 0..=surface { + world.set_block(IVec3::new(0, y, 0), Block::Air); + } + let spawn = find_safe_spawn(&world); + // Spawn y should still be the natural surface + 1 — the player will + // fall into the hole on the next physics tick, which is correct + // behavior (the spawn point itself didn't move). + assert_eq!(spawn.y as i32, surface + 1); + } + + // ---------- move_axis: collision invariants ---------- + + #[test] + fn move_axis_passes_freely_through_air() { + let world = World::new(); + let mut feet = Vec3::new(0.5, 60.0, 0.5); + let hit = move_axis(&world, &mut feet, -1.0, Axis::Y); + assert!(!hit); + assert!((feet.y - 59.0).abs() < 1e-3); + } + + #[test] + fn move_axis_blocks_against_ground() { + let world = World::new(); + let surface = natural_surface_y(0, 0); + let mut feet = Vec3::new(0.5, (surface + 1) as f32 + 0.01, 0.5); + // Sink 5 blocks downward — should snap to top of surface block. + let hit = move_axis(&world, &mut feet, -5.0, Axis::Y); + assert!(hit, "moving into the ground must register a hit"); + assert!( + feet.y >= (surface + 1) as f32, + "feet must rest on or above surface; got y={}", + feet.y + ); + assert!( + feet.y < (surface + 1) as f32 + 0.05, + "feet should snap to surface, not float; got y={}", + feet.y + ); + } + + // ---------- merge_held: the bug that ate playtests ---------- + // + // Original bug: tick() did `self.input.forward = self.input.forward || br.forward`. + // Releasing the joystick set `br.forward = false`, but the OR kept + // `self.input.forward` true forever, so the player walked indefinitely + // (and the jump button latched on the same way). These tests pin the + // behavior that lifting both sources of "forward" must actually stop. + + #[test] + fn merge_held_passes_through_keyboard_alone() { + let kb = KbHeld { forward: true, ..Default::default() }; + let br = TouchBridge::default(); + let m = merge_held(&kb, &br); + assert!(m.forward); + assert!(!m.back); + assert!(!m.up); + } + + #[test] + fn merge_held_passes_through_bridge_alone() { + let kb = KbHeld::default(); + let br = TouchBridge { forward: true, jump: true, ..Default::default() }; + let m = merge_held(&kb, &br); + assert!(m.forward); + assert!(m.up); + } + + #[test] + fn merge_held_releases_when_bridge_releases() { + let kb = KbHeld::default(); + let br_pressed = TouchBridge { forward: true, ..Default::default() }; + let br_released = TouchBridge::default(); + assert!(merge_held(&kb, &br_pressed).forward); + // The crucial assertion: stepping from "bridge held" to "bridge + // released" must immediately read as not-pressed. The pre-fix code + // failed this because it folded the prior `true` into a persistent + // field via OR. + assert!(!merge_held(&kb, &br_released).forward); + } + + #[test] + fn merge_held_releases_jump_too() { + let kb = KbHeld::default(); + let br_jumping = TouchBridge { jump: true, ..Default::default() }; + let br_idle = TouchBridge::default(); + assert!(merge_held(&kb, &br_jumping).up); + assert!(!merge_held(&kb, &br_idle).up, + "releasing the jump button must clear `up` so the player stops bouncing"); + } + + #[test] + fn merge_held_kb_wins_even_if_bridge_drops() { + // While W is held, the joystick releasing shouldn't make the player + // stop — keyboard is an independent source. + let kb = KbHeld { forward: true, ..Default::default() }; + let br = TouchBridge::default(); + assert!(merge_held(&kb, &br).forward); + } + + // ---------- emit_oriented_box: rotation correctness ---------- + + fn cross_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] { + 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 oriented_box_emits_six_quads() { + let mut v = vec![]; + let mut i = vec![]; + emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), 0.0, [1.0; 3], &mut v, &mut i); + assert_eq!(v.len(), 24); + assert_eq!(i.len(), 36); + } + + #[test] + fn oriented_box_winds_correctly_at_any_yaw() { + // At every yaw the cross-product per triangle must agree with the + // (rotated) stored normal. + for &yaw in &[0.0_f32, 0.7, 1.5708, 3.14, -1.0, 5.0] { + let mut v = vec![]; + let mut i = vec![]; + emit_oriented_box(Vec3::new(5.0, 7.0, -3.0), Vec3::new(0.3, 0.6, 0.2), yaw, [1.0; 3], &mut v, &mut i); + for tri in i.chunks_exact(3) { + let a = v[tri[0] as usize].pos; + let b = v[tri[1] as usize].pos; + let c = v[tri[2] as usize].pos; + let n = v[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, + "yaw {} triangle [{},{},{}] winds opposite normal {:?}", + yaw, tri[0], tri[1], tri[2], n + ); + } + } + } + + #[test] + fn oriented_box_normal_rotates_with_yaw() { + // At yaw = 90° the local +X face's normal should point at world +Z. + let mut v = vec![]; + let mut i = vec![]; + let yaw = std::f32::consts::FRAC_PI_2; + emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), yaw, [1.0; 3], &mut v, &mut i); + // The first face emitted is +X local (see emit_oriented_box). Its + // vertices are 0..3. Their stored normal should be ~ (0, 0, 1). + let n = v[0].normal; + assert!( + (n[0]).abs() < 1e-5 && (n[2] - 1.0).abs() < 1e-5, + "+X normal at yaw 90° expected (0,0,1), got {:?}", + n + ); + } + + #[test] + fn move_axis_does_not_let_player_enter_a_solid_block() { + let world = World::new(); + let surface = natural_surface_y(0, 0); + let mut feet = Vec3::new(0.5, (surface + 1) as f32, 0.5); + let _ = move_axis(&world, &mut feet, -100.0, Axis::Y); + assert!( + !player_overlaps_solid(&world, feet), + "after a downward sweep the player must never end up inside a block" + ); + } +} diff --git a/src/world.rs b/src/world.rs new file mode 100644 index 0000000..cf81580 --- /dev/null +++ b/src/world.rs @@ -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, + 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, +} + +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"); + } +} diff --git a/web/.eslintrc.json b/web/.eslintrc.json new file mode 100644 index 0000000..ba88197 --- /dev/null +++ b/web/.eslintrc.json @@ -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": "^_" }] + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c852b9e --- /dev/null +++ b/web/index.html @@ -0,0 +1,632 @@ + + + + +Voxel Game + + + + + +
+ +
+ Voxel Game
+ WASD move   Space jump   Ctrl sprint
+ Click canvas to lock mouse · LMB break · RMB place
+ 16 pick block +
+ +
+ + offline + 🎮 + +
+ +
+
+
20 / 20
+
+ +
+
grass
+
dirt
+
stone
+
sand
+
wood
+
leaves
+
cobble
+
brick
+
snow
+
ice
+
+ + + +
+

YOU DIED

+ +
+ + + + + + + + + + diff --git a/web/main.js b/web/main.js new file mode 100644 index 0000000..e6c9b91 --- /dev/null +++ b/web/main.js @@ -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", + `
${err}
`); +}); + +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); +}