mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
731d88a1d8 | ||
|
|
903da4f1ab | ||
|
|
54bb3e4fbe | ||
|
|
03a5cbdcd8 | ||
|
|
b7b4a26164 | ||
|
|
8b4336013a | ||
|
|
1c3be171ce | ||
|
|
befec1995f | ||
|
|
05396b4edf | ||
|
|
7fd1f3118f | ||
|
|
4309d96924 | ||
|
|
478b8c19c4 | ||
|
|
2af75ba9f6 | ||
|
|
89888e9b07 | ||
|
|
1110a28521 | ||
|
|
f6e451162c | ||
|
|
6ce6577886 | ||
|
|
8f2aa7f7e9 | ||
|
|
4513af8995 | ||
|
|
8177a6f450 | ||
|
|
f6b882c9da | ||
|
|
9e248a3214 | ||
|
|
781b7b877b | ||
|
|
50f4c913a5 | ||
|
|
e555c4ee48 | ||
|
|
f5adaec209 | ||
|
|
406fbd7793 | ||
|
|
c0567a323c | ||
|
|
0713ee5ec0 | ||
|
|
4604491fcb | ||
|
|
b3840adccd | ||
|
|
6f8cad0020 | ||
|
|
1087739079 | ||
|
|
d8ed068db8 | ||
|
|
2ec2964962 | ||
|
|
bb414b263c | ||
|
|
f2832f83ef | ||
|
|
b873f30c29 | ||
|
|
8c54e18587 | ||
|
|
0dfc6d8700 | ||
|
|
0298d10988 | ||
|
|
75951a769a | ||
|
|
94bc569a7f | ||
|
|
50727eb5c3 | ||
|
|
65d3edfc3a | ||
|
|
ee74553314 | ||
|
|
6d3cd330d1 | ||
|
|
204859a2cb | ||
|
|
af7a685b5c | ||
|
|
5bfc649edd | ||
|
|
0c017678eb | ||
|
|
10efca9e6c | ||
|
|
436c2fefb4 | ||
|
|
f48cfb7966 | ||
|
|
0481dbdb81 | ||
|
|
13e83e4e63 | ||
|
|
3843a9a4aa | ||
|
|
467277ef6d | ||
|
|
18ec4145c4 | ||
|
|
ed7d88b213 | ||
|
|
a2fccf0205 | ||
|
|
dbafda06ed | ||
|
|
e181b17e17 | ||
|
|
f56785c2f5 | ||
|
|
68deac8533 | ||
|
|
2f1f0150eb | ||
|
|
806f7d87f3 | ||
|
|
df626cf2f3 | ||
|
|
9e21868dfa | ||
|
|
be378bb773 | ||
|
|
4032e162ce | ||
|
|
1ec6d6d734 | ||
|
|
e7f85e5bfd | ||
|
|
348675c538 |
No files matched your search
@@ -82,7 +82,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
|
||||
Generated
+90
@@ -1116,12 +1116,44 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[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.30.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
|
||||
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-allocator"
|
||||
version = "0.28.0"
|
||||
@@ -1352,6 +1384,13 @@ dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods-test-ui-guest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
@@ -1805,6 +1844,23 @@ dependencies = [
|
||||
"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 = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -3142,6 +3198,15 @@ 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.16.2"
|
||||
@@ -4267,6 +4332,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"wgpu-core-deps-apple",
|
||||
"wgpu-core-deps-emscripten",
|
||||
"wgpu-core-deps-windows-linux-android",
|
||||
"wgpu-hal",
|
||||
"wgpu-naga-bridge",
|
||||
@@ -4282,6 +4348,15 @@ dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-emscripten"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-windows-linux-android"
|
||||
version = "30.0.1"
|
||||
@@ -4304,13 +4379,18 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"glow",
|
||||
"glutin_wgl_sys",
|
||||
"gpu-allocator",
|
||||
"hashbrown 0.17.1",
|
||||
"js-sys",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"naga",
|
||||
"naga-types",
|
||||
"ndk-sys",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -4330,10 +4410,14 @@ dependencies = [
|
||||
"smallvec",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.21",
|
||||
"wasm-bindgen",
|
||||
"wayland-sys",
|
||||
"web-sys",
|
||||
"wgpu-naga-bridge",
|
||||
"wgpu-types",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-result 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5074,6 +5158,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
+619
-13
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs.
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
@@ -80,6 +80,16 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
|
||||
|
||||
</details>
|
||||
|
||||
#### Pre-generate an area with /pregen
|
||||
|
||||
A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Horizon's mod registry
|
||||
@@ -94,6 +104,40 @@ More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods that run on the server
|
||||
|
||||
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Screens, input and drawing for mods
|
||||
|
||||
Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Tools for people who make mods
|
||||
|
||||
Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
|
||||
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Single player as a server: the last differences
|
||||
@@ -130,6 +174,16 @@ More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](http
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon's own shader format and kit
|
||||
|
||||
Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
@@ -160,11 +214,65 @@ A review of the whole tree: dead code and stubs removed ([#1432](https://github.
|
||||
|
||||
#### Fresh benchmarks against vanilla
|
||||
|
||||
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, on the website's Benchmarks page.
|
||||
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, with a history that shows what each release changed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Faster world creation and entry on small CPUs
|
||||
|
||||
Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 chunks can make you wait minutes for the first terrain.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Servers that hold 20 TPS with 50 to 100 players
|
||||
|
||||
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
|
||||
|
||||
</details>
|
||||
|
||||
#### Low-end computers: Steam Deck, old Macs, 8 GB
|
||||
|
||||
Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
|
||||
|
||||
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Native upscaling
|
||||
|
||||
Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain frames on weaker graphics cards. An optional graphics setting.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/ludovic111/horizon/issues/1403).
|
||||
|
||||
</details>
|
||||
|
||||
#### A batch of targeted optimisations
|
||||
|
||||
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -180,6 +288,126 @@ The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-
|
||||
|
||||
</details>
|
||||
|
||||
#### A Convert to Rust button for what isn't rewritten yet
|
||||
|
||||
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods with code: machines, magic, mobs
|
||||
|
||||
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Data packs in the mod browser
|
||||
|
||||
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Prepare a version without Java
|
||||
|
||||
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Server console, bans and a full admin API
|
||||
|
||||
Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Hosting polish: Windows service, safe updates
|
||||
|
||||
Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each player's state, and a changed view distance or message without a restart.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
|
||||
|
||||
</details>
|
||||
|
||||
#### Guided hosting with Tailscale and an invite button
|
||||
|
||||
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
|
||||
|
||||
More: [docs/hosting.md](docs/hosting.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### An official Docker image for servers
|
||||
|
||||
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
|
||||
|
||||
More: [docs/server.md](docs/server.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
|
||||
Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
|
||||
|
||||
More: [docs/modpacks.md](docs/modpacks.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Machines and blocks with inventories (API 0.4)
|
||||
|
||||
Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/ludovic111/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Lua and other languages for mods
|
||||
|
||||
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Pistons push players and mobs
|
||||
@@ -192,6 +420,50 @@ Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (hon
|
||||
|
||||
</details>
|
||||
|
||||
#### One single-player path: remove the in-frame world
|
||||
|
||||
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Villages and combat, exactly as in vanilla
|
||||
|
||||
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Choose the audio output device
|
||||
|
||||
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Well-known shader looks, rewritten for Horizon
|
||||
|
||||
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Microsoft sign-in (coming soon)
|
||||
@@ -206,6 +478,18 @@ More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
|
||||
|
||||
</details>
|
||||
|
||||
#### A visible beta label
|
||||
|
||||
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Platforms
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
|
||||
Builds of the game and the launcher for Macs with an Intel processor.
|
||||
@@ -216,29 +500,351 @@ x86_64 game and launcher builds, picked per processor ([#1171](https://github.co
|
||||
|
||||
</details>
|
||||
|
||||
## Later
|
||||
#### Wayland and Hyprland polish
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Following new Minecraft versions
|
||||
|
||||
When Mojang releases a new version of the game, Horizon follows it, and every system is checked against the new version again.
|
||||
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The port pipeline detects a release, diffs its classes against 26.3 and writes a port report ([docs/version-ports.md](docs/version-ports.md)); the harnesses then run against the new version.
|
||||
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
|
||||
|
||||
</details>
|
||||
|
||||
### AI and agents
|
||||
|
||||
#### A versioned agent-control protocol
|
||||
|
||||
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
|
||||
|
||||
More: [docs/api/stability.md](docs/api/stability.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Port mods with your own AI agent
|
||||
|
||||
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
</details>
|
||||
|
||||
### Community
|
||||
|
||||
#### Horizon on X (coming soon)
|
||||
|
||||
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### Pipeline protection and a GitHub App for agents
|
||||
|
||||
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Signed updates everywhere, installers included
|
||||
|
||||
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
|
||||
</details>
|
||||
|
||||
## Later
|
||||
|
||||
### Performance
|
||||
|
||||
#### Exact far terrain from real chunks
|
||||
|
||||
Far terrain drawn from the world you really built and explored, caves and builds included, not only a preview from the seed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### Older Fabric and Forge mods
|
||||
|
||||
Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over like newer ones.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### A modpack's own resource and shader packs
|
||||
|
||||
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Whole modpacks, such as ATM10, rewritten
|
||||
|
||||
Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: the work above comes first.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of its 496 mods are carried at least in part ([docs/modpacks.md](docs/modpacks.md)). It depends on 'Mods with code' and the native mod API.
|
||||
|
||||
</details>
|
||||
|
||||
#### Running Java mods that aren't rewritten
|
||||
|
||||
Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Ely.by and other sign-in providers
|
||||
|
||||
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Online-mode checks for Play with Friends
|
||||
|
||||
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Play with Friends joins over an encrypted tunnel with an invite code ([docs/play-with-friends.md](docs/play-with-friends.md), 'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between players, with no relay run by the project.
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
|
||||
|
||||
</details>
|
||||
|
||||
#### Native versions of popular client mods
|
||||
|
||||
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Create-style physics as a native mod
|
||||
|
||||
Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
|
||||
|
||||
</details>
|
||||
|
||||
#### Proximity voice chat as a native mod
|
||||
|
||||
Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Every Minecraft version, prepared automatically
|
||||
|
||||
Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, Horizon follows it, and every system is checked against it again.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
|
||||
|
||||
</details>
|
||||
|
||||
#### Controllers: tested on real pads, and in the launcher
|
||||
|
||||
Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in the launcher too.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
|
||||
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### A light default shader pack
|
||||
|
||||
A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players who want a nicer picture without a heavy pack.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it.
|
||||
|
||||
More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Optional ray tracing
|
||||
|
||||
An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water reflections and global lighting on top of the vanilla look.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
|
||||
|
||||
</details>
|
||||
|
||||
#### HDR monitors
|
||||
|
||||
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
|
||||
|
||||
</details>
|
||||
|
||||
#### MSAA, anisotropic filtering, exclusive fullscreen
|
||||
|
||||
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Lean installs that use less disk
|
||||
|
||||
Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the preparation needed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current and the previous version kept, and an automatic clean-up. Not started.
|
||||
|
||||
</details>
|
||||
|
||||
### Platforms
|
||||
|
||||
#### Horizon on Linux ARM64
|
||||
|
||||
Game and launcher builds for 64-bit ARM Linux computers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
|
||||
|
||||
</details>
|
||||
|
||||
#### OpenGL for old computers
|
||||
|
||||
A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like the DirectX 12 fallback that already ships.
|
||||
|
||||
More: [docs/graphics.md](docs/graphics.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Android, iOS and the browser
|
||||
|
||||
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### An optional AI teammate
|
||||
#### A complete video studio inside the game
|
||||
|
||||
An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the No AI switch hides it completely.
|
||||
Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and finish the video. One of Horizon's original goals.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)), and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). Local models first; it ships only once the safety evaluation passes.
|
||||
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
|
||||
|
||||
</details>
|
||||
|
||||
### AI and agents
|
||||
|
||||
#### An optional AI teammate
|
||||
|
||||
An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, runs on your computer first, and exists only if you turn it on. The No AI switch hides it.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
|
||||
|
||||
</details>
|
||||
|
||||
### Community
|
||||
|
||||
#### A video series about Horizon
|
||||
|
||||
A series of videos about how Horizon is made and what is new, with each upload announced on Discord.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the project: a script, edits, titles and a thumbnail per video.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -225,6 +225,8 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
});
|
||||
}
|
||||
match failed(&mods) {
|
||||
|
||||
@@ -68,6 +68,11 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -49,7 +49,7 @@ pub fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new());
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let case: Value = serde_json::from_str(&line?)?;
|
||||
|
||||
@@ -152,7 +152,7 @@ pub fn main() -> Result<()> {
|
||||
world(&assets, &facts, false, true)?,
|
||||
world(&assets, &facts, true, true)?,
|
||||
];
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new());
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
|
||||
for (ty, item) in [
|
||||
(ParticleType::ItemSlime, "item_slime_ball"),
|
||||
(ParticleType::ItemSnowball, "item_snowball"),
|
||||
|
||||
@@ -617,9 +617,23 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.head_pitch = pitch;
|
||||
m.on_ground = flag(tag, "OnGround");
|
||||
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
if let Some(h) = float(tag.get("Health")) {
|
||||
m.health = h as f32;
|
||||
// LivingEntity.readAdditionalSaveData: the saved attribute bases, then
|
||||
// the health, the max health when there is none.
|
||||
if let Some(Tag::List(attrs)) = tag.get("attributes") {
|
||||
for a in attrs {
|
||||
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
|
||||
continue;
|
||||
};
|
||||
let v = f64::from_bits(*bits);
|
||||
match id.as_str() {
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
@@ -735,12 +749,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
|
||||
continue;
|
||||
};
|
||||
let v = f64::from_bits(*bits);
|
||||
match id.as_str() {
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:jump_strength" => m.extra.vector[0] = v,
|
||||
_ => {}
|
||||
if id == "minecraft:jump_strength" {
|
||||
m.extra.vector[0] = f64::from_bits(*bits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +482,9 @@ enum Screen {
|
||||
CommandBlock,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods
|
||||
/// (game/mods.rs).
|
||||
Mod,
|
||||
}
|
||||
struct Mining {
|
||||
pos: [i32; 3],
|
||||
@@ -1168,6 +1171,8 @@ struct Game<'a> {
|
||||
carried: Option<ItemStack>,
|
||||
craft_grid: Vec<Option<ItemStack>>,
|
||||
craft_size: usize,
|
||||
/// The crafting table the 3x3 grid belongs to (`CraftingMenu.access`).
|
||||
craft_table: Option<[i32; 3]>,
|
||||
/// A quick craft drag over inventory slots: (button, game slots so far).
|
||||
inv_drag: Option<(i32, Vec<usize>)>,
|
||||
/// `AbstractContainerScreen`'s quick craft and skip-release state.
|
||||
@@ -1837,6 +1842,19 @@ impl Game<'_> {
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
})
|
||||
}
|
||||
/// `LivingEntity.canFreeze`'s armor half: any worn piece in
|
||||
/// `freeze_immune_wearables`.
|
||||
pub(crate) fn wears_freeze_immune(&self) -> bool {
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
self.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)))
|
||||
}
|
||||
/// What the window decides for its player's body every tick, in frame and
|
||||
/// on a server alike: the look (Player.travel steers sprint-swimming by
|
||||
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
|
||||
@@ -1863,16 +1881,7 @@ impl Game<'_> {
|
||||
let leather_boots = self.armor[3]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
let freeze_immune = self
|
||||
.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
|
||||
let freeze_immune = self.wears_freeze_immune();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -2173,6 +2182,11 @@ impl Game<'_> {
|
||||
// Player.drop: the drop statistics.
|
||||
self.stats.add("dropped", &thrown.item, thrown.count as i32);
|
||||
self.stats.custom("drop", 1);
|
||||
self.throw_from_eyes(thrown, eye);
|
||||
}
|
||||
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
|
||||
/// along the view direction.
|
||||
fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) {
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
f64::from(self.pitch).to_radians(),
|
||||
@@ -2916,6 +2930,7 @@ impl Game<'_> {
|
||||
}
|
||||
if &*name == "minecraft:crafting_table" {
|
||||
self.craft_size = 3;
|
||||
self.craft_table = Some(pos);
|
||||
} else if &*name == "minecraft:ender_chest" {
|
||||
self.open_ender_chest(key);
|
||||
self.container_opener_event(key, true);
|
||||
@@ -4335,7 +4350,8 @@ impl Game<'_> {
|
||||
// Out of the world.
|
||||
let _ = player.set_health(0.0, false);
|
||||
}
|
||||
if player.health.dead_or_dying() && self.screen != Screen::Dead {
|
||||
let dead = player.health.dead_or_dying();
|
||||
if dead && self.screen != Screen::Dead {
|
||||
// Inventory.dropAll: every stack thrown in a random direction.
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
let at = [feet[0], feet[1] + 1.32, feet[2]];
|
||||
@@ -4369,6 +4385,18 @@ impl Game<'_> {
|
||||
}
|
||||
self.screen = Screen::Dead;
|
||||
self.capture(false);
|
||||
self.ui.death_time = 0;
|
||||
} else if dead {
|
||||
// LivingEntity.tickDeath: 20 ticks after dying the player is
|
||||
// removed, and Player.remove's inventoryMenu.removed drops what
|
||||
// the cursor held.
|
||||
self.ui.death_time += 1;
|
||||
if self.ui.death_time >= 20
|
||||
&& let Some(stack) = self.carried.take()
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.throw_from_eyes(stack, eye);
|
||||
}
|
||||
}
|
||||
self.tick_world(running)
|
||||
}
|
||||
@@ -5047,6 +5075,7 @@ impl Game<'_> {
|
||||
self.draw_creative(&mut batch);
|
||||
}
|
||||
Screen::Chat => self.draw_chat_screen(&mut batch),
|
||||
Screen::Mod => self.draw_mod_screen(&mut batch),
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_toasts(&mut batch);
|
||||
@@ -5425,6 +5454,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.craft_size = 2;
|
||||
self.craft_table = None;
|
||||
if let Some(key) = self.container.take() {
|
||||
self.container_opener_event(key, false);
|
||||
self.post_block_game_event(
|
||||
@@ -7879,8 +7909,12 @@ impl Game<'_> {
|
||||
(0..width).all(|i| frame([left[0] + d[0] * i, left[1] + height, left[2] + d[2] * i]));
|
||||
((3..=21).contains(&height) && top_ok).then_some((left, width, height))
|
||||
}
|
||||
/// BaseFireBlock.onPlace: lighting fire inside a frame creates a portal.
|
||||
/// BaseFireBlock.onPlace: lighting fire inside a frame creates a portal,
|
||||
/// only in the Overworld and the Nether (`inPortalDimension`).
|
||||
fn try_light_portal(&mut self, at: [i32; 3]) -> Result<bool> {
|
||||
if !matches!(self.dim, Dim::Overworld | Dim::Nether) {
|
||||
return Ok(false);
|
||||
}
|
||||
for x_axis in [true, false] {
|
||||
if let Some((origin, width, height)) = self.portal_shape(at, x_axis) {
|
||||
let d = if x_axis { [1, 0, 0] } else { [0, 0, 1] };
|
||||
@@ -10363,6 +10397,7 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
Screen::Chat => self.close_chat(),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::Mod => self.close_mod_screen(true),
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -11009,6 +11044,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::Options && d != 0.0 {
|
||||
self.options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Mod && d != 0.0 {
|
||||
self.mod_screen_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Creative && d != 0.0 {
|
||||
self.creative_screen.wheel(f64::from(d), &self.pack.names);
|
||||
}
|
||||
@@ -11231,6 +11269,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
self.stats_click(size);
|
||||
}
|
||||
Screen::Mod if pressed && button == MouseButton::Left => {
|
||||
self.mod_screen_click()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -11709,7 +11750,11 @@ impl<'a> Game<'a> {
|
||||
let inventory = vec![None; 36];
|
||||
let now = Instant::now();
|
||||
let look = options.look;
|
||||
let particles = horizon_client::particles::ParticleEngine::new(&root, &gui.atlas.sprites);
|
||||
let particles = horizon_client::particles::ParticleEngine::new(
|
||||
&root,
|
||||
&gui.atlas.sprites,
|
||||
&gui.atlas.translucent,
|
||||
);
|
||||
Ok(Game {
|
||||
res: res.clone(),
|
||||
rest_loader: None,
|
||||
@@ -11746,6 +11791,7 @@ impl<'a> Game<'a> {
|
||||
carried: None,
|
||||
craft_grid: vec![None; 9],
|
||||
craft_size: 2,
|
||||
craft_table: None,
|
||||
inv_drag: None,
|
||||
tweaks: Default::default(),
|
||||
inv_clicks: Default::default(),
|
||||
|
||||
@@ -416,6 +416,7 @@ impl Game<'_> {
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -1599,8 +1600,12 @@ impl Game<'_> {
|
||||
if self.screen != Screen::Dead {
|
||||
return Err("respawn needs the death screen".into());
|
||||
}
|
||||
let label =
|
||||
horizon_client::text::Text::translate("deathScreen.respawn", &[]).plain();
|
||||
let key = if self.level.hardcore {
|
||||
"deathScreen.spectate"
|
||||
} else {
|
||||
"deathScreen.respawn"
|
||||
};
|
||||
let label = horizon_client::text::Text::translate(key, &[]).plain();
|
||||
let w = widgets::find(&widgets::last(), &label)?;
|
||||
if !w.enabled {
|
||||
// DeathScreen enables its buttons after 20 ticks.
|
||||
|
||||
@@ -15,6 +15,8 @@ use horizon_world::registry::BlockStateId;
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_block_entities(&mut self) -> Result<()> {
|
||||
self.tick_mount_menu();
|
||||
self.tick_craft_table();
|
||||
self.tick_container_reach();
|
||||
self.load_entity_types();
|
||||
if !self.world.block_entities.has_states() {
|
||||
let states = self.ticking_states();
|
||||
|
||||
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
const BOARDING_COOLDOWN: i32 = 60;
|
||||
pub(super) const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
@@ -99,6 +99,8 @@ pub struct UiState {
|
||||
/// Death screen cause and when the player died (buttons wait 20 ticks).
|
||||
pub death_text: Option<Text>,
|
||||
pub died_at: Option<std::time::Instant>,
|
||||
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
|
||||
pub death_time: u32,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -586,7 +588,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
let line = Text::translate(
|
||||
"chat.type.text",
|
||||
&[Text::literal(PLAYER_NAME), Text::literal(message)],
|
||||
&[self.player_display_name(), Text::literal(message)],
|
||||
);
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.running().then(|| line.plain());
|
||||
|
||||
@@ -214,7 +214,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Player.getDisplayName: the name (team-formatted), suggesting a
|
||||
/// `/tell`, showing the player on hover, inserting the name.
|
||||
fn player_display_name(&self) -> Text {
|
||||
pub(super) fn player_display_name(&self) -> Text {
|
||||
let name = self.acting_player_name().to_owned();
|
||||
let Some(uuid) = self.lan_acting_uuid else {
|
||||
return self.target_name(Target::Player);
|
||||
@@ -2809,7 +2809,7 @@ impl Game<'_> {
|
||||
let name = match self.command_source.map(|s| s.entity) {
|
||||
Some(Some(t)) => self.target_name(t),
|
||||
Some(None) => Text::literal("Server"),
|
||||
None => Text::literal(self.acting_player_name()),
|
||||
None => self.player_display_name(),
|
||||
};
|
||||
let line = match id {
|
||||
"say" => Text::translate("chat.type.announcement", &[name, message]),
|
||||
@@ -2819,14 +2819,14 @@ impl Game<'_> {
|
||||
// Whispering to yourself: the incoming and outgoing lines.
|
||||
let out = Text::translate(
|
||||
"commands.message.display.outgoing",
|
||||
&[Text::literal(self.acting_player_name()), message.clone()],
|
||||
&[self.player_display_name(), message.clone()],
|
||||
)
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.system_message(out);
|
||||
Text::translate(
|
||||
"commands.message.display.incoming",
|
||||
&[Text::literal(self.acting_player_name()), message],
|
||||
&[self.player_display_name(), message],
|
||||
)
|
||||
.with_color(text::GRAY)
|
||||
.italic()
|
||||
|
||||
@@ -606,10 +606,7 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some("vehicle") => match entity {
|
||||
Target::Player => self.riding.map(Target::Mob).into_iter().collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
|
||||
Some("controller") | Some("passengers") => match entity {
|
||||
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
|
||||
vec![Target::Player]
|
||||
|
||||
@@ -1074,15 +1074,15 @@ impl Game<'_> {
|
||||
};
|
||||
let name = self.target_name(target);
|
||||
if id == "ride.dismount" {
|
||||
let vehicle = match target {
|
||||
Target::Player => self.riding.map(Target::Mob),
|
||||
_ => None,
|
||||
};
|
||||
let Some(v) = vehicle else {
|
||||
let Some(v) = self.ride_vehicle(target) else {
|
||||
return Err(err("commands.ride.not_riding", &[&name.plain()]));
|
||||
};
|
||||
let vehicle_name = self.target_name(v);
|
||||
self.dismount_mount();
|
||||
match target {
|
||||
Target::Player => self.dismount_mount(),
|
||||
Target::Mob(rider) => self.dismount_mob(rider),
|
||||
_ => {}
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.dismount.success",
|
||||
&[name, vehicle_name],
|
||||
@@ -1094,11 +1094,8 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let vehicle_name = self.target_name(vehicle);
|
||||
if target == Target::Player && self.riding.is_some() {
|
||||
let current = self
|
||||
.riding
|
||||
.map(Target::Mob)
|
||||
.map_or_else(Text::empty, |v| self.target_name(v));
|
||||
if let Some(current) = self.ride_vehicle(target) {
|
||||
let current = self.target_name(current);
|
||||
return Err(err(
|
||||
"commands.ride.already_riding",
|
||||
&[&name.plain(), ¤t.plain()],
|
||||
@@ -1107,22 +1104,42 @@ impl Game<'_> {
|
||||
if vehicle == Target::Player {
|
||||
return Err(err("commands.ride.mount.failure.cant_ride_players", &[]));
|
||||
}
|
||||
if target == vehicle {
|
||||
return Err(err("commands.ride.mount.failure.loop", &[]));
|
||||
// getSelfAndPassengers: the vehicle is the target or rides it.
|
||||
let mut up = Some(vehicle);
|
||||
while let Some(v) = up {
|
||||
if v == target {
|
||||
return Err(err("commands.ride.mount.failure.loop", &[]));
|
||||
}
|
||||
up = self.ride_vehicle(v);
|
||||
}
|
||||
// The player can ride mobs that carry a rider.
|
||||
// Players and mobs can ride mobs.
|
||||
let Target::Mob(mob_id) = vehicle else {
|
||||
return Err(err(
|
||||
"commands.ride.mount.failure.generic",
|
||||
&[&name.plain(), &vehicle_name.plain()],
|
||||
));
|
||||
};
|
||||
if target != Target::Player || !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) {
|
||||
let rider = match target {
|
||||
Target::Mob(id) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
if !(target == Target::Player || rider.is_some())
|
||||
|| !self.mobs.iter().any(|m| m.id == mob_id && m.alive())
|
||||
{
|
||||
return Err(err(
|
||||
"commands.ride.mount.failure.generic",
|
||||
&[&name.plain(), &vehicle_name.plain()],
|
||||
));
|
||||
}
|
||||
if let Some(rider) = rider {
|
||||
// Entity.startRiding.
|
||||
self.mount_mob(rider, mob_id);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) {
|
||||
m.extra.flag = false;
|
||||
m.extra.counter = 0;
|
||||
@@ -1138,6 +1155,47 @@ impl Game<'_> {
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// Entity.getVehicle: the player's mount, or a mob's.
|
||||
pub(super) fn ride_vehicle(&self, t: Target) -> Option<Target> {
|
||||
let id = match t {
|
||||
Target::Player => return self.riding.map(Target::Mob),
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(if self.boats.iter().any(|b| b.id == id) {
|
||||
Target::Boat(id)
|
||||
} else {
|
||||
Target::Mob(id)
|
||||
})
|
||||
}
|
||||
/// LivingEntity.stopRiding for a mob: off its vehicle
|
||||
/// (Entity.removePassenger), then to where the vehicle lets it out
|
||||
/// (LivingEntity.dismountVehicle).
|
||||
fn dismount_mob(&mut self, rider: u32) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
|
||||
return;
|
||||
};
|
||||
let Some(v) = self.mobs[i].extra.mount.take() else {
|
||||
return;
|
||||
};
|
||||
let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw);
|
||||
let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) {
|
||||
self.boat_dismount_target(b, width, yaw)
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == v) {
|
||||
m.extra.rider = None;
|
||||
// Entity.getDismountLocationForPassenger: on top of the vehicle.
|
||||
let p = m.position;
|
||||
horizon_core::geometry::Vec3::new(p.x, p.y + f64::from(m.height()), p.z)
|
||||
} else {
|
||||
self.mobs[i].position
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
m.extra.passenger = false;
|
||||
m.extra.boarding_cooldown = super::super::boat_passengers::BOARDING_COOLDOWN;
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
}
|
||||
|
||||
// ---- /spreadplayers -----------------------------------------------------------------------
|
||||
|
||||
pub(super) fn cmd_spreadplayers(&mut self, ctx: &Context) -> R<i32> {
|
||||
|
||||
@@ -799,6 +799,7 @@ impl Game<'_> {
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|m| m.distance_sqr(center) <= radius * radius)
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
let mut state = serde_json::json!({
|
||||
"id": m.id,
|
||||
"kind": m.kind.id,
|
||||
"pos": [m.position.x, m.position.y, m.position.z],
|
||||
@@ -370,7 +370,11 @@ impl Game<'_> {
|
||||
"patrol_target": s.patrol_target,
|
||||
"vehicle": m.extra.mount,
|
||||
})),
|
||||
})
|
||||
});
|
||||
// Drawn seated (Entity.isPassenger); set apart because the
|
||||
// json! above is at the macro recursion limit.
|
||||
state["passenger"] = m.extra.passenger.into();
|
||||
state
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "count": self.mobs.len(), "near": list, "projectiles": self.creatures.projectiles.len() })
|
||||
@@ -1694,7 +1698,26 @@ impl Game<'_> {
|
||||
targets: targets_guests,
|
||||
};
|
||||
let guest_entities = self.lan_guest_entities();
|
||||
// ThrownEnderpearl.tick: a dead player's pearl is discarded in
|
||||
// place of its tick while ender_pearls_vanish_on_death is on.
|
||||
let vanish = self.ui.rules.bool("ender_pearls_vanish_on_death");
|
||||
let host_dead = self.player_view().is_some_and(|p| !p.alive);
|
||||
let dead_guests: Vec<u32> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect()
|
||||
});
|
||||
projectiles.retain_mut(|p| {
|
||||
if vanish
|
||||
&& matches!(p.kind, ProjectileKind::EnderPearl)
|
||||
&& p.owner == Some(Who::Player)
|
||||
&& p.guest.map_or(host_dead, |c| dead_guests.contains(&c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
// A player's arrows, snowballs and eggs meet every player; a
|
||||
// guest's flies from that guest (Projectile.getOwner), not
|
||||
|
||||
@@ -55,8 +55,8 @@ pub(super) struct LanHost {
|
||||
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
|
||||
/// Blocks broken this tick (their sound is a level event for the guests).
|
||||
pub broken: Vec<[f64; 3]>,
|
||||
/// Hurt and death state of the mobs guests see.
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool)>>,
|
||||
/// Hurt time, death and swing time (while swinging) of the mobs guests see.
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
|
||||
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
|
||||
pub mob_players: HashMap<u32, u32>,
|
||||
/// An integrated server's owner (game/integrated.rs).
|
||||
@@ -168,11 +168,15 @@ fn mob_meta(
|
||||
meta.push((9, serializer::FLOAT, Value::Float(health)));
|
||||
}
|
||||
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2.
|
||||
let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1;
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
|
||||
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
|
||||
// weapon).
|
||||
let flags =
|
||||
i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1 | i8::from(m.ai.aggressive) << 2;
|
||||
if flags != 0 {
|
||||
meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
defaults.push((15, serializer::BYTE, Value::Byte(0)));
|
||||
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
|
||||
if let Some(index) = baby_index(kind) {
|
||||
if m.is_baby() {
|
||||
@@ -1193,7 +1197,17 @@ impl Game<'_> {
|
||||
if dead && !old.is_some_and(|o| o.1) {
|
||||
host.hub.entity_event(m.id as i32, 3, at);
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead));
|
||||
// LivingEntity.swing on a server (a melee hit, a thrown potion):
|
||||
// ClientboundSwingAnimationPacket for the players around. A swing
|
||||
// started since the last flush, or restarted (its time went back).
|
||||
let swing = m.ai.swinging.then_some(m.ai.swing_time);
|
||||
if let Some(time) = swing
|
||||
&& !dead
|
||||
&& old.and_then(|o| o.2).is_none_or(|before| time < before)
|
||||
{
|
||||
host.hub.swing(m.id as i32, 0, at, None);
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ pub(super) struct GuestSwap {
|
||||
host_command_block: Option<super::command_blocks::CommandBlockScreen>,
|
||||
host_mount_menu: super::mount_menu::MountMenu,
|
||||
craft_size: usize,
|
||||
craft_table: Option<[i32; 3]>,
|
||||
craft_grid: Vec<Option<ItemStack>>,
|
||||
container: Option<(i32, i32, i32)>,
|
||||
mining: Option<Mining>,
|
||||
@@ -307,6 +308,7 @@ impl Game<'_> {
|
||||
// The host's crafting grid stays theirs; a crafting table the guest
|
||||
// opens shows as the 3x3 size.
|
||||
let craft_size = std::mem::replace(&mut self.craft_size, 2);
|
||||
let craft_table = self.craft_table.take();
|
||||
let craft_grid = std::mem::replace(&mut self.craft_grid, vec![None; 9]);
|
||||
let container = self.container.take();
|
||||
let mining = std::mem::replace(&mut self.mining, play.mining.take());
|
||||
@@ -390,6 +392,7 @@ impl Game<'_> {
|
||||
host_command_block,
|
||||
host_mount_menu,
|
||||
craft_size,
|
||||
craft_table,
|
||||
craft_grid,
|
||||
container,
|
||||
mining,
|
||||
@@ -445,6 +448,7 @@ impl Game<'_> {
|
||||
host_command_block,
|
||||
host_mount_menu,
|
||||
craft_size,
|
||||
craft_table,
|
||||
craft_grid,
|
||||
container,
|
||||
mining,
|
||||
@@ -495,7 +499,10 @@ impl Game<'_> {
|
||||
|
||||
// What the action opened: handed to the guest instead of the host.
|
||||
let opened = self.container.take();
|
||||
let opened_table = std::mem::replace(&mut self.craft_size, craft_size) == 3;
|
||||
let guest_table = std::mem::replace(&mut self.craft_table, craft_table);
|
||||
let opened_table = (std::mem::replace(&mut self.craft_size, craft_size) == 3)
|
||||
.then_some(guest_table)
|
||||
.flatten();
|
||||
self.craft_grid = craft_grid;
|
||||
let opened_station = std::mem::replace(&mut self.station, host_station);
|
||||
let bed = std::mem::replace(&mut self.sleeping, sleeping);
|
||||
@@ -671,8 +678,8 @@ impl Game<'_> {
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
if opened_table {
|
||||
self.lan_open_crafting(host, conn);
|
||||
if let Some(pos) = opened_table {
|
||||
self.lan_open_crafting(host, conn, pos);
|
||||
}
|
||||
if let Some(mob) = guest_mount {
|
||||
self.lan_open_mount(host, conn, mob);
|
||||
@@ -812,6 +819,31 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.tick`'s `stillValid`: a guest's block container closes
|
||||
/// once the guest is out of reach of its block (a removed block closes it
|
||||
/// in the hub's `sync_containers`).
|
||||
fn lan_containers_tick(&mut self, host: &mut LanHost) {
|
||||
let far: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| {
|
||||
g.menu.as_ref().is_some_and(|m| {
|
||||
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
|
||||
})
|
||||
})
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in far {
|
||||
self.guest_container_closed(host, conn);
|
||||
host.hub.close_container(conn);
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A packet from a guest that the hub does not handle itself.
|
||||
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -1075,7 +1107,20 @@ impl Game<'_> {
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
let rules = ItemRules(self.res.items());
|
||||
let slots = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| m.window == p.container)
|
||||
.map(|m| m.slots);
|
||||
let before = slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi]));
|
||||
let drops = host.hub.container_click(conn, &p, &mut self.world, &rules);
|
||||
// A click that changed the container's slots: BlockEntity.setChanged,
|
||||
// so comparators read it again (a double chest's other half follows).
|
||||
if let Some(Slots::Block(key) | Slots::Double(key, _)) = slots
|
||||
&& slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi])) != before
|
||||
{
|
||||
self.container_changed(key);
|
||||
}
|
||||
self.drop_guest_items(host, gi, drops);
|
||||
}
|
||||
S::ContainerClose(p) => {
|
||||
@@ -1231,14 +1276,39 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Component::Potion {
|
||||
potion: Some(id), ..
|
||||
}) = s.component(component::POTION_CONTENTS)
|
||||
&& let Some(potion) = host.hub.registry_name("minecraft:potion", *id)
|
||||
{
|
||||
out = out.with_potion(potion);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `LivingEntity.tickDeath`: 20 ticks after dying the player is removed,
|
||||
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
|
||||
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
|
||||
/// sooner removes it too.
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.death_time += 1;
|
||||
if g.death_time >= 20 {
|
||||
self.guest_drop_carried(host, gi);
|
||||
}
|
||||
}
|
||||
|
||||
fn guest_drop_carried(&mut self, host: &mut LanHost, gi: usize) {
|
||||
if let Some(c) = host.hub.guests[gi].carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed`: what a closing player held on the
|
||||
/// cursor goes back into their inventory (`placeItemBackInInventory`,
|
||||
/// dropped only if it doesn't fit), and their client's cursor empties
|
||||
/// with the inventory it is sent.
|
||||
fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
|
||||
pub(super) fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
|
||||
if let Some(c) = host.hub.guests[gi].carried.take() {
|
||||
self.lan_place_back(host, gi, vec![c]);
|
||||
host.hub.send_inventory(gi);
|
||||
@@ -1573,6 +1643,8 @@ impl Game<'_> {
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
// PlayerList.respawn removes the dead player (Player.remove).
|
||||
self.guest_drop_carried(host, i);
|
||||
// PlayerList.respawn: a server's player comes back in its respawn
|
||||
// point's level: the Overworld, where the world spawn and beds are,
|
||||
// or another one's anchor or forced point, which its block resolves
|
||||
@@ -1613,6 +1685,7 @@ impl Game<'_> {
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.dead = false;
|
||||
g.death_time = 0;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
g.saturation = 5.0;
|
||||
@@ -1649,6 +1722,11 @@ impl Game<'_> {
|
||||
border: &self.border,
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if self.level.hardcore {
|
||||
host.hub.set_game_mode(conn, SPECTATOR);
|
||||
}
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
}
|
||||
@@ -1680,6 +1758,8 @@ impl Game<'_> {
|
||||
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
|
||||
self.lan_merchant_tick(host);
|
||||
self.lan_stations_tick(host);
|
||||
self.lan_tables_tick(host);
|
||||
self.lan_containers_tick(host);
|
||||
self.tick_lan_mount_menus(host);
|
||||
self.announce_guest_advancements(host);
|
||||
let conns: Vec<u32> = host
|
||||
@@ -1694,7 +1774,11 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
self.guest_sleep_tick(host, gi);
|
||||
if host.hub.guests[gi].dead || self.on_server_trip(conn) {
|
||||
if host.hub.guests[gi].dead {
|
||||
self.guest_tick_death(host, gi);
|
||||
continue;
|
||||
}
|
||||
if self.on_server_trip(conn) {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
@@ -1942,6 +2026,7 @@ impl Game<'_> {
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let freeze_immune = game.wears_freeze_immune();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
@@ -2005,6 +2090,7 @@ impl Game<'_> {
|
||||
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
|
||||
// lit campfires and grown sweet berry bushes hurt (#1305).
|
||||
w.player.shift_key_down = sneaking;
|
||||
w.player.freeze_immune = freeze_immune;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
|
||||
@@ -19,8 +19,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
pub(super) struct GuestCrafting {
|
||||
/// The inventory menu; its grid lives in the hub's guest.
|
||||
inventory: Option<Box<Menu>>,
|
||||
/// The open crafting table: its window, state id and menu.
|
||||
table: Option<(i32, i32, Box<Menu>)>,
|
||||
/// The open crafting table: its window, state id, menu and block.
|
||||
table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
|
||||
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
|
||||
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
|
||||
/// container menu keeps the client in sync.
|
||||
@@ -383,17 +383,17 @@ impl Game<'_> {
|
||||
host.hub.send_inventory(gi);
|
||||
placed
|
||||
} else {
|
||||
let Some((window, state, mut menu)) = host
|
||||
let Some((window, state, mut menu, pos)) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|play| play.crafting.table.take_if(|(w, _, _)| *w == p.container))
|
||||
.and_then(|play| play.crafting.table.take_if(|(w, ..)| *w == p.container))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
menu.place_recipe(env, &id, p.use_max, creative, None)
|
||||
});
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
|
||||
self.lan_send_table_content(host, conn);
|
||||
placed
|
||||
};
|
||||
@@ -403,7 +403,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
@@ -413,7 +413,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let menu = Box::new(Menu::new(Kind::Crafting));
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, 0, menu));
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, 0, menu, pos));
|
||||
self.lan_send_table_content(host, conn);
|
||||
}
|
||||
|
||||
@@ -427,31 +427,72 @@ impl Game<'_> {
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return false;
|
||||
};
|
||||
let Some((window, state, mut menu)) = play
|
||||
let Some((window, state, mut menu, pos)) = play
|
||||
.crafting
|
||||
.table
|
||||
.take_if(|(window, _, _)| *window == p.container)
|
||||
.take_if(|(window, ..)| *window == p.container)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// A table that is no longer valid takes no clicks (handleContainerClick).
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
&& self.lan_table_valid(&host.hub.guests[gi], pos)
|
||||
{
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
|
||||
});
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
|
||||
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
|
||||
self.lan_send_table_content(host, conn);
|
||||
true
|
||||
}
|
||||
|
||||
/// `CraftingMenu.stillValid`: the block is still a crafting table and the
|
||||
/// guest is within reach of it.
|
||||
fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name;
|
||||
&**name == "minecraft:crafting_table" && g.within_block_range(pos, 4.0)
|
||||
}
|
||||
|
||||
/// `ServerPlayer.tick`'s `stillValid`: a guest's crafting table closes
|
||||
/// once it is gone or out of reach, giving its grid back.
|
||||
pub(super) fn lan_tables_tick(&mut self, host: &mut LanHost) {
|
||||
let invalid: Vec<(u32, i32)> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter_map(|(c, p)| p.crafting.table.as_ref().map(|t| (*c, t.0, t.3)))
|
||||
.filter(|&(c, _, pos)| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == c)
|
||||
.is_some_and(|g| host.hub.here(g) && !self.lan_table_valid(g, pos))
|
||||
})
|
||||
.map(|(c, window, _)| (c, window))
|
||||
.collect();
|
||||
for (conn, window) in invalid {
|
||||
self.lan_crafting_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerClose(play::ContainerClose { container: window }),
|
||||
);
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole crafting table menu for the client (`sendAllDataToRemote`).
|
||||
fn lan_send_table_content(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let Some((window, state, menu)) = host
|
||||
let Some((window, state, menu, _)) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| p.crafting.table.as_mut())
|
||||
@@ -499,9 +540,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
match crafting
|
||||
.table
|
||||
.take_if(|(w, _, _)| window.is_none_or(|window| *w == window))
|
||||
.take_if(|(w, ..)| window.is_none_or(|window| *w == window))
|
||||
{
|
||||
Some((_, _, menu)) => (1..10).filter_map(|s| menu.slot(s).cloned()).collect(),
|
||||
Some((_, _, menu, _)) => (1..10).filter_map(|s| menu.slot(s).cloned()).collect(),
|
||||
None => return false,
|
||||
}
|
||||
};
|
||||
@@ -527,7 +568,7 @@ impl Game<'_> {
|
||||
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
|
||||
g.craft_result = None;
|
||||
if let Some(play) = play {
|
||||
if let Some((_, _, menu)) = play.crafting.table {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
}
|
||||
// A workstation's inputs too.
|
||||
|
||||
@@ -588,7 +588,7 @@ impl Game<'_> {
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
let block = StationKind::from_block(name) == Some(gs.kind);
|
||||
if !(block && within_reach(g, gs.pos)) {
|
||||
if !(block && g.within_block_range(gs.pos, 4.0)) {
|
||||
let window = gs.window;
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
@@ -631,16 +631,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some()
|
||||
);
|
||||
// Container.stillValidBlockEntity: the interaction range and its buffer.
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
let pos = [key.0, key.1, key.2];
|
||||
let near = (0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach;
|
||||
let near = g.within_block_range([key.0, key.1, key.2], 4.0);
|
||||
if has_book && near {
|
||||
self.lan_send_lectern(host, conn, false);
|
||||
} else {
|
||||
@@ -660,18 +651,3 @@ pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
|
||||
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 4)`: the guest's eyes within
|
||||
/// its block interaction range (4.5, creative 5) and a 4-block buffer of the
|
||||
/// block's box.
|
||||
pub(super) fn within_reach(g: &horizon_client::lan::guest::Guest, pos: [i32; 3]) -> bool {
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
(0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach
|
||||
}
|
||||
@@ -20,10 +20,13 @@ use {
|
||||
horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud,
|
||||
items::ItemStack,
|
||||
mod_list::Installed,
|
||||
options_ui::Page,
|
||||
text::{self, Text},
|
||||
ui,
|
||||
},
|
||||
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status},
|
||||
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget},
|
||||
std::time::Instant,
|
||||
};
|
||||
|
||||
@@ -39,6 +42,23 @@ pub(super) struct ModsState {
|
||||
/// The chat's command tree while a running mod owns commands.
|
||||
#[cfg(feature = "mods")]
|
||||
tree: Option<Dispatcher>,
|
||||
/// A `ui` mod's screen or settings page while it is up.
|
||||
#[cfg(feature = "mods")]
|
||||
screen: Option<ModScreen>,
|
||||
}
|
||||
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods: the
|
||||
/// widgets the mod pushed, laid out and drawn with the menus' own widgets.
|
||||
#[cfg(feature = "mods")]
|
||||
struct ModScreen {
|
||||
/// The running mod's index in `mods.mods.entries()`.
|
||||
owner: usize,
|
||||
/// Opened from Options > Mods: Done and Esc return there.
|
||||
settings: bool,
|
||||
/// What the mod's screen replaced (Playing, Chat, ...), to restore.
|
||||
under: Screen,
|
||||
/// One scroll per `list` widget, in widget order.
|
||||
scrolls: Vec<ui::Scroll>,
|
||||
}
|
||||
impl ModsState {
|
||||
/// The chat's command tree: vanilla's, plus the running mods' commands.
|
||||
@@ -166,6 +186,11 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn mods_chat(&mut self, _line: &str) {}
|
||||
pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {}
|
||||
pub(super) fn open_mod_screen(&mut self, _owner: usize, _settings: bool) {}
|
||||
pub(super) fn close_mod_screen(&mut self, _tell: bool) {}
|
||||
pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {}
|
||||
pub(super) fn mod_screen_click(&mut self) {}
|
||||
pub(super) fn mod_screen_scroll(&mut self, _delta: f32) {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
@@ -187,6 +212,8 @@ impl Game<'_> {
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load(&paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
// Entries were replaced wholesale: a screen's owner index is stale.
|
||||
self.mods.screen = None;
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
self.show_mod_output();
|
||||
@@ -212,6 +239,7 @@ impl Game<'_> {
|
||||
description: String::new(),
|
||||
problem,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
};
|
||||
};
|
||||
// What it may do first: the row cuts long descriptions.
|
||||
@@ -227,6 +255,7 @@ impl Game<'_> {
|
||||
description: format!("{may} {}", m.description).trim().to_owned(),
|
||||
problem,
|
||||
dev: dev.contains(&e.path),
|
||||
has_settings: e.has_ui_settings(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -343,10 +372,22 @@ impl Game<'_> {
|
||||
Output::Feedback { text, error: false } => Text::literal(text),
|
||||
Output::Feedback { text, error: true } => Text::literal(text).with_color(text::RED),
|
||||
Output::Stopped { name, reason } => {
|
||||
// Its screen goes with it: the entry's run is gone, so
|
||||
// `mod_ui_view` finds no widgets and the screen closes.
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
|
||||
}
|
||||
Output::OpenScreen { owner } => {
|
||||
self.open_mod_screen(owner, false);
|
||||
continue;
|
||||
}
|
||||
Output::CloseScreen { owner } => {
|
||||
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
|
||||
self.close_mod_screen(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Not through system_message: a mod must not hear its own lines.
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -354,6 +395,12 @@ impl Game<'_> {
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
}
|
||||
// A mod may set or clear its settings page in any call, not only
|
||||
// while loading: its row's Settings button follows.
|
||||
let rows = self.settings.mods.installed.iter_mut();
|
||||
for (row, e) in rows.zip(self.mods.mods.entries()) {
|
||||
row.has_settings = e.has_ui_settings();
|
||||
}
|
||||
}
|
||||
/// The mods' HUD panels, stacked with the extras in their corners.
|
||||
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
@@ -376,4 +423,297 @@ impl Game<'_> {
|
||||
extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ui` screen's title and widgets as the mod last pushed them; None
|
||||
/// when the surface is gone (the mod stopped or closed it).
|
||||
fn mod_ui_view(&self) -> Option<(String, Vec<UiWidget>)> {
|
||||
let ms = self.mods.screen.as_ref()?;
|
||||
if ms.settings {
|
||||
let widgets = self.mods.mods.settings_widgets(ms.owner);
|
||||
if widgets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self
|
||||
.mods
|
||||
.mods
|
||||
.entries()
|
||||
.get(ms.owner)
|
||||
.and_then(|e| e.manifest.as_ref())
|
||||
.map_or("Mod", |m| m.name.as_str());
|
||||
Some((name.to_string(), widgets.to_vec()))
|
||||
} else {
|
||||
let s: &UiScreen = self.mods.mods.screen(ms.owner)?;
|
||||
Some((s.title.clone(), s.widgets.clone()))
|
||||
}
|
||||
}
|
||||
/// The layout the mod screen's widgets get at `h`: (widget, top,
|
||||
/// height), the column being `COLUMN` wide down the middle.
|
||||
fn mod_ui_layout(&self, h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> {
|
||||
let mut out = Vec::with_capacity(widgets.len());
|
||||
let mut y = MOD_UI_TOP;
|
||||
for w in widgets {
|
||||
let wh = match w {
|
||||
UiWidget::Text(_) => 12.0,
|
||||
UiWidget::Item(_) => 20.0,
|
||||
UiWidget::List(l) => (l.rows.len().min(6) as f32 * 12.0) + 8.0,
|
||||
};
|
||||
// Nothing may cover Done; a crowded mod loses what won't fit.
|
||||
if y + wh > h - 34.0 {
|
||||
break;
|
||||
}
|
||||
out.push((y, wh));
|
||||
y += wh + 6.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
/// A mod pushes its screen (`ui.open-screen`), or Options > Mods opens
|
||||
/// its settings page (`settings`, the mod's `installed` index).
|
||||
pub(super) fn open_mod_screen(&mut self, owner: usize, settings: bool) {
|
||||
if self.title.is_some() {
|
||||
return;
|
||||
}
|
||||
if settings {
|
||||
self.mods.mods.ui_settings_opened(owner);
|
||||
if self.mods.mods.settings_widgets(owner).is_empty() {
|
||||
self.show_mod_output();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.mods.screen = Some(ModScreen {
|
||||
owner,
|
||||
settings,
|
||||
under: self.screen,
|
||||
scrolls: Vec::new(),
|
||||
});
|
||||
self.screen = Screen::Mod;
|
||||
if self.player.is_some() {
|
||||
self.capture(false);
|
||||
}
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Leaves the mod screen: back to Options for a settings page, to what
|
||||
/// it covered for a mod's own. `tell` is Esc/Done, heard as `closed`.
|
||||
pub(super) fn close_mod_screen(&mut self, tell: bool) {
|
||||
let Some(ms) = self.mods.screen.take() else {
|
||||
return;
|
||||
};
|
||||
if ms.settings {
|
||||
self.open_options_at(self.options_from_title, Page::Mods);
|
||||
} else {
|
||||
self.screen = ms.under;
|
||||
if self.screen == Screen::Playing && self.player.is_some() {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
if tell {
|
||||
self.mods.mods.ui_closed(ms.owner);
|
||||
self.show_mod_output();
|
||||
}
|
||||
}
|
||||
/// The mod's screen or settings page: its title over widgets stacked
|
||||
/// down the middle, a Done button, Esc to leave (vanilla's menus).
|
||||
pub(super) fn draw_mod_screen(&mut self, batch: &mut HudBatch) {
|
||||
if self.mods.screen.is_none() {
|
||||
self.screen = Screen::Playing;
|
||||
return;
|
||||
}
|
||||
let Some((title, widgets)) = self.mod_ui_view() else {
|
||||
self.mods.screen = None;
|
||||
self.screen = Screen::Playing;
|
||||
return;
|
||||
};
|
||||
// Item icons draw in a second pass: `draw_item` needs `&mut self`,
|
||||
// so `atlas` may not stay borrowed across it.
|
||||
let mut items: Vec<(String, f32, f32)> = Vec::new();
|
||||
let atlas = &self.gui.atlas;
|
||||
let in_world = self.player.is_some();
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, batch.width, batch.height, in_world);
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(title),
|
||||
0.0,
|
||||
15.0,
|
||||
batch.width,
|
||||
u32::MAX,
|
||||
);
|
||||
let cx = (batch.width / 2.0).floor();
|
||||
let x = cx - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(batch.height, &widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
match w {
|
||||
UiWidget::Text(t) => {
|
||||
let color = if t.color == 0 {
|
||||
u32::MAX
|
||||
} else {
|
||||
t.color | 0xFF00_0000
|
||||
};
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(t.text.clone()),
|
||||
x,
|
||||
wy + 1.0,
|
||||
MOD_UI_COLUMN,
|
||||
color,
|
||||
);
|
||||
}
|
||||
UiWidget::Item(i) => {
|
||||
if self.res.items().get(&i.item).is_some() {
|
||||
items.push((i.item.clone(), x + 2.0, wy + 2.0));
|
||||
} else {
|
||||
batch.text(atlas, "?", x + 6.0, wy + 6.0, u32::MAX, true);
|
||||
}
|
||||
let name = if i.name.is_empty() {
|
||||
i.item.rsplit(':').next().unwrap_or(&i.item).to_string()
|
||||
} else {
|
||||
i.name.clone()
|
||||
};
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(name),
|
||||
x + 22.0,
|
||||
wy + 6.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
UiWidget::List(l) => {
|
||||
let scroll = {
|
||||
let ms = self.mods.screen.as_mut().expect("checked");
|
||||
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
|
||||
ms.scrolls[list_i]
|
||||
};
|
||||
list_i += 1;
|
||||
let rows_top = wy + 4.0;
|
||||
let rows_bottom = wy + wh - 4.0;
|
||||
batch.fill(atlas, x, wy, MOD_UI_COLUMN, wh, 0x8000_0000);
|
||||
for (b, yy) in [(x, wy), (x, wy + wh - 1.0)] {
|
||||
batch.fill(atlas, b, yy, MOD_UI_COLUMN, 1.0, 0xFF50_5050);
|
||||
}
|
||||
for (xx, yy) in [(x, wy), (x + MOD_UI_COLUMN - 1.0, wy)] {
|
||||
batch.fill(atlas, xx, yy, 1.0, wh, 0xFF50_5050);
|
||||
}
|
||||
let content = l.rows.len() as f32 * 12.0;
|
||||
let first = (scroll.amount / 12.0) as usize;
|
||||
let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1)
|
||||
.min(l.rows.len());
|
||||
for (vi, ri) in (first..last).enumerate() {
|
||||
let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0);
|
||||
if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) {
|
||||
batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF);
|
||||
}
|
||||
if l.selected == Some(ri as u32) {
|
||||
batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF);
|
||||
}
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal(l.rows[ri].clone()),
|
||||
x + 6.0,
|
||||
ry + 2.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
x + MOD_UI_COLUMN - 5.0,
|
||||
rows_top,
|
||||
rows_bottom,
|
||||
content,
|
||||
scroll.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let hover = ui::hovered(self.cursor, cx - 100.0, batch.height - 27.0, 200.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
cx - 100.0,
|
||||
batch.height - 27.0,
|
||||
200.0,
|
||||
20.0,
|
||||
&Text::literal("Done"),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
for (id, ix, iy) in items {
|
||||
let stack = ItemStack::new(id.as_str(), 1);
|
||||
self.draw_item(batch, &stack, ix, iy);
|
||||
}
|
||||
}
|
||||
/// A click on the mod screen: Done leaves it, a `list` row is the mod's
|
||||
/// `ui-events::select`.
|
||||
pub(super) fn mod_screen_click(&mut self) {
|
||||
let (w, h) = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
if ui::hovered(cursor, (w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.close_mod_screen(true);
|
||||
return;
|
||||
}
|
||||
let Some((_, widgets)) = self.mod_ui_view() else {
|
||||
return;
|
||||
};
|
||||
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(h, &widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
let UiWidget::List(l) = w else { continue };
|
||||
let scroll = self
|
||||
.mods
|
||||
.screen
|
||||
.as_ref()
|
||||
.and_then(|ms| ms.scrolls.get(list_i).copied())
|
||||
.unwrap_or_default();
|
||||
list_i += 1;
|
||||
let row = ((cursor.1 - (wy + 4.0) + scroll.amount) / 12.0) as i64;
|
||||
if ui::hovered(cursor, x + 1.0, wy + 1.0, MOD_UI_COLUMN - 7.0, wh - 2.0)
|
||||
&& row >= 0
|
||||
&& (row as usize) < l.rows.len()
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
self.mods.mods.ui_select(
|
||||
self.mods.screen.as_ref().map_or(0, |ms| ms.owner),
|
||||
l.id,
|
||||
row as u32,
|
||||
);
|
||||
self.show_mod_output();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The wheel over a `list` scrolls it.
|
||||
pub(super) fn mod_screen_scroll(&mut self, delta: f32) {
|
||||
let (w, h) = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
let Some((_, widgets)) = self.mod_ui_view() else {
|
||||
return;
|
||||
};
|
||||
let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0;
|
||||
let layout = self.mod_ui_layout(h, &widgets);
|
||||
let mut list_i = 0usize;
|
||||
for (w, &(wy, wh)) in widgets.iter().zip(&layout) {
|
||||
let UiWidget::List(l) = w else { continue };
|
||||
let over = ui::hovered(cursor, x, wy, MOD_UI_COLUMN, wh);
|
||||
let Some(ms) = self.mods.screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
ms.scrolls.resize_with(list_i + 1, ui::Scroll::default);
|
||||
if over {
|
||||
let content = l.rows.len() as f32 * 12.0;
|
||||
ms.scrolls[list_i].wheel(delta, 12.0, wy + 4.0, wy + wh - 4.0, content);
|
||||
return;
|
||||
}
|
||||
list_i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_TOP: f32 = 40.0;
|
||||
#[cfg(feature = "mods")]
|
||||
const MOD_UI_COLUMN: f32 = 200.0;
|
||||
@@ -237,6 +237,7 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,15 +76,14 @@ impl Game<'_> {
|
||||
let pos = ChunkPos::new(chunk.x, chunk.z);
|
||||
let time = self.world.game_time;
|
||||
let mut extras: ChunkExtras = chunk.saved.as_deref().cloned().unwrap_or_default();
|
||||
// A chunk the scheduler hasn't registered yet (one loaded and
|
||||
// unloaded again before the next tick) still holds its saved ticks:
|
||||
// `register_chunk_ticks` empties that list once it takes them.
|
||||
if let Some(t) = self.block_ticks.blocks.chunk(pos) {
|
||||
extras.block_ticks = t.pack(time);
|
||||
} else {
|
||||
extras.block_ticks.clear();
|
||||
}
|
||||
if let Some(t) = self.block_ticks.fluids.chunk(pos) {
|
||||
extras.fluid_ticks = t.pack(time);
|
||||
} else {
|
||||
extras.fluid_ticks.clear();
|
||||
}
|
||||
extras.last_update = time;
|
||||
// TheEndGatewayBlockEntity saves its Age (the server copy's).
|
||||
|
||||
@@ -27,6 +27,11 @@ impl Game<'_> {
|
||||
if ready.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// The ticks the marks schedule go to the chunks' containers, which a
|
||||
// chunk that arrived since the last tick doesn't have yet
|
||||
// (`LevelChunk.registerTickContainerInLevel` comes before
|
||||
// `postProcessGeneration`).
|
||||
self.sync_tick_chunks();
|
||||
for key in ready {
|
||||
let Some(chunk) = self.world.chunks.get_mut(&key) else {
|
||||
continue;
|
||||
|
||||
@@ -700,6 +700,16 @@ impl Game<'_> {
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// PotionContents' NBT: the potion by name and the custom colour.
|
||||
Component::Potion { potion, color, .. } => {
|
||||
let potion = potion.and_then(|id| r.builtin.get("minecraft:potion")?.name(id));
|
||||
Tag::compound(
|
||||
potion
|
||||
.map(|name| ("potion", Tag::String(name.into())))
|
||||
.into_iter()
|
||||
.chain(color.map(|c| ("custom_color", Tag::Int(c)))),
|
||||
)
|
||||
}
|
||||
// ItemStackTemplate.CODEC: id, count and components.
|
||||
Component::Stacks(stacks) => Tag::List(
|
||||
stacks
|
||||
@@ -761,6 +771,20 @@ impl Game<'_> {
|
||||
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
|
||||
let s = s.as_ref()?;
|
||||
let mut stack = Stack::new(self.item_id(&s.item), s.count as i32);
|
||||
// A creative pick keeps its potion (PotionContents by its builtin id).
|
||||
if let Some(id) = s.potion().and_then(|p| {
|
||||
let potions = self.remote.as_ref()?.builtin.get("minecraft:potion")?;
|
||||
potions.id(&horizon_world::datafix::dynamic::namespaced(p))
|
||||
}) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::POTION_CONTENTS,
|
||||
horizon_protocol::item::Component::Potion {
|
||||
potion: Some(id),
|
||||
color: None,
|
||||
rest: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
if s.damage > 0 {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::DAMAGE,
|
||||
@@ -1576,10 +1600,10 @@ impl Game<'_> {
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
// The vehicle the player rode is gone (Entity.removeVehicle).
|
||||
if self.riding == Some(id as u32) {
|
||||
self.riding = None;
|
||||
}
|
||||
// Entity.setRemoved detaches every passenger, even when no empty
|
||||
// SetPassengers packet preceded the vehicle's removal. Otherwise
|
||||
// mobs and other players keep their passenger pose after it is gone.
|
||||
self.remote_set_passengers(id, &[]);
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.dropped.retain(|d| d.id != id as u32);
|
||||
@@ -2046,15 +2070,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
{
|
||||
for (index, _, value) in &p.values {
|
||||
if let (15, Value::Byte(flags)) = (index, value) {
|
||||
m.no_ai = flags & 1 != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.remote_mob_data(p.id, &p.values);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Creeper
|
||||
@@ -4061,8 +4076,14 @@ impl Game<'_> {
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
} else if let Some(stack) = held.as_ref() {
|
||||
self.predict_item_on_block(stack, &hit);
|
||||
} else if let Some(stack) = held.as_ref()
|
||||
&& self.predict_item_on_block(stack, &hit)
|
||||
{
|
||||
// Item.useOn succeeded here (InteractionResult.SUCCESS):
|
||||
// this hand swings and the click ends, so the other hand
|
||||
// (a shield, a torch) is never tried.
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
}
|
||||
if held_block {
|
||||
let placed = if reacts {
|
||||
@@ -4092,7 +4113,8 @@ impl Game<'_> {
|
||||
/// `playSound(player, ...)`: the server sends them to the others only):
|
||||
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
|
||||
/// block (stripping, scraping, removing wax, tilling, making a path).
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) {
|
||||
/// Whether the item's use succeeded, which ends the click.
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) -> bool {
|
||||
let at = [
|
||||
f64::from(hit.pos[0]) + 0.5,
|
||||
f64::from(hit.pos[1]) + 0.5,
|
||||
@@ -4111,11 +4133,15 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
|
||||
}
|
||||
waxes
|
||||
} else if let Some((_, sound, _, _, particle)) =
|
||||
self.block_transform(stack, hit.pos, hit.face)
|
||||
{
|
||||
self.transform_particle(&particle, hit.pos);
|
||||
self.audio.play(&sound, Some(at), 1.0, 1.0);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
|
||||
// animation follow it.
|
||||
(9, Value::Float(health)) => m.health = *health,
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4.
|
||||
// Mob.isAggressive draws the illagers' and skeletons' attack
|
||||
// poses and the vindicator's axe.
|
||||
(15, Value::Byte(flags)) => {
|
||||
m.no_ai = flags & 1 != 0;
|
||||
m.extra.left_handed = flags & 2 != 0;
|
||||
m.ai.aggressive = flags & 4 != 0;
|
||||
}
|
||||
(i, Value::Bool(b)) if Some(i) == baby => m.extra.age = if *b { -1 } else { 0 },
|
||||
_ => {}
|
||||
}
|
||||
@@ -178,6 +186,19 @@ mod tests {
|
||||
assert_eq!(m.fire_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mob_flags_turn_aggression_on_and_off() {
|
||||
let Some(mut m) = mob("minecraft:vindicator") else {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(4 | 2))]);
|
||||
assert!(m.ai.aggressive && m.extra.left_handed && !m.no_ai);
|
||||
assert!(horizon_client::mobs::illager::shows_item(&m));
|
||||
apply(&mut m, &[(15, s::BYTE, Value::Byte(0))]);
|
||||
assert!(!m.ai.aggressive && !m.extra.left_handed);
|
||||
assert!(!horizon_client::mobs::illager::shows_item(&m));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_villager_takes_its_type_profession_and_level() {
|
||||
let Some(mut m) = mob("minecraft:villager") else {
|
||||
|
||||
@@ -332,6 +332,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.load_scoreboard();
|
||||
host.hub.teams.clone_from(&self.score.board.teams);
|
||||
let view = horizon_client::lan::scoreboard::View::of(&self.score.board);
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
if g.scoreboard != view {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
command::args::GameMode,
|
||||
hud::{HudBatch, rgba},
|
||||
text::{self, Text},
|
||||
ui,
|
||||
@@ -17,7 +18,8 @@ impl Screen {
|
||||
| Self::Stats
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends => true,
|
||||
| Self::Friends
|
||||
| Self::Mod => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
| Self::Chat
|
||||
@@ -81,9 +83,15 @@ fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
|
||||
(x0 + 72.0, y, "gui/sprites/pause_menu/player_reporting"),
|
||||
]
|
||||
}
|
||||
fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
|
||||
/// DeathScreen.init: a hardcore player can only spectate.
|
||||
fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8); 2] {
|
||||
let respawn = if hardcore {
|
||||
"deathScreen.spectate"
|
||||
} else {
|
||||
"deathScreen.respawn"
|
||||
};
|
||||
[
|
||||
(w / 2.0 - 100.0, h / 4.0 + 72.0, "deathScreen.respawn", 0),
|
||||
(w / 2.0 - 100.0, h / 4.0 + 72.0, respawn, 0),
|
||||
(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0,
|
||||
@@ -223,7 +231,12 @@ impl Game<'_> {
|
||||
),
|
||||
);
|
||||
}
|
||||
let title = Text::translate("deathScreen.title", &[]);
|
||||
let title = if self.level.hardcore {
|
||||
"deathScreen.title.hardcore"
|
||||
} else {
|
||||
"deathScreen.title"
|
||||
};
|
||||
let title = Text::translate(title, &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
ui::text_scaled(
|
||||
batch,
|
||||
@@ -258,7 +271,7 @@ impl Game<'_> {
|
||||
.ui
|
||||
.died_at
|
||||
.is_none_or(|t| t.elapsed().as_millis() >= 1000);
|
||||
for (x, y, key, _) in death_buttons(w, h) {
|
||||
for (x, y, key, _) in death_buttons(w, h, self.level.hardcore) {
|
||||
let hover = active && ui::hovered(self.cursor, x, y, 200.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -286,7 +299,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let b = HudBatch::new(size);
|
||||
let hit = death_buttons(b.width, b.height)
|
||||
let hit = death_buttons(b.width, b.height, self.level.hardcore)
|
||||
.into_iter()
|
||||
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
|
||||
.map(|d| d.3);
|
||||
@@ -330,6 +343,11 @@ impl Game<'_> {
|
||||
Some(1) => self.close_world()?,
|
||||
_ => {}
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if hit == Some(0) && self.remote.is_none() && self.level.hardcore {
|
||||
self.set_mode(GameMode::Spectator);
|
||||
}
|
||||
if hit.is_some() {
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ impl Game<'_> {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let near = super::lan_stations::within_reach(g, p.pos);
|
||||
let near = g.within_block_range(p.pos, 4.0);
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -383,6 +383,8 @@ impl Loading {
|
||||
self.menu.win = Some((screen, Instant::now()));
|
||||
}
|
||||
}
|
||||
// Mods' settings pages open only in a running game.
|
||||
Event::ModSettings(_) => {}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,52 @@ impl Game<'_> {
|
||||
None => vec![own],
|
||||
}
|
||||
}
|
||||
/// `Container.stillValidBlockEntity`: the open block container closes once
|
||||
/// the player's eyes are out of reach of any of its blocks (a 4-block buffer).
|
||||
pub(super) fn tick_container_reach(&mut self) {
|
||||
// A server's menu ends when the server closes it.
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.container
|
||||
.is_none_or(|k| k.1 == super::minecarts::CART_KEY_Y)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let range = self.reach() + 4.0;
|
||||
let near = self.container_parts().iter().all(|&((x, y, z), _)| {
|
||||
horizon_client::lan::guest::within_block_range(eye, range, [x, y, z])
|
||||
});
|
||||
if !near {
|
||||
self.close_container();
|
||||
self.screen = super::Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `CraftingMenu.stillValid`: the open crafting table closes once its
|
||||
/// block is no longer a crafting table or the player's eyes are out of
|
||||
/// reach (a 4-block buffer).
|
||||
pub(super) fn tick_craft_table(&mut self) {
|
||||
// A server's menu ends when the server closes it.
|
||||
if self.remote.is_some() || self.craft_size != 3 {
|
||||
return;
|
||||
}
|
||||
let (Some(pos), Some(eye)) = (self.craft_table, self.eye_position()) else {
|
||||
return;
|
||||
};
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let table = &*self.res.facts().get(state).name == "minecraft:crafting_table";
|
||||
let range = self.reach() + 4.0;
|
||||
if !(table && horizon_client::lan::guest::within_block_range(eye, range, pos)) {
|
||||
self.close_container();
|
||||
self.screen = super::Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// The number of slots of the open container.
|
||||
pub(super) fn container_size(&self) -> usize {
|
||||
self.container_parts().iter().map(|p| p.1).sum()
|
||||
|
||||
@@ -35,7 +35,7 @@ fn entity_shader() -> String {
|
||||
mod entity_batch;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{SHADOW_OVERLAY, WATER_MASK_OVERLAY};
|
||||
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
pub fn outline_width(width: u32) -> f32 {
|
||||
@@ -683,25 +683,31 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
|
||||
}
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends
|
||||
/// itself. Only Windows has two: Auto tries Vulkan, then DirectX 12 when
|
||||
/// Vulkan has no adapter (no Vulkan driver) or no device. A native crash in
|
||||
/// the driver can't be caught in the game; the launcher switches the setting
|
||||
/// to DirectX 12 after one at the renderer's start (#746).
|
||||
fn backend_order(api: GraphicsApi, windows: bool, wgpu_backend: bool) -> Vec<wgpu::Backends> {
|
||||
if !windows || wgpu_backend {
|
||||
return vec![wgpu::Backends::default()];
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
/// which picks the backends itself. Auto tries Vulkan, then DirectX 12 on Windows,
|
||||
/// then OpenGL (Windows and Linux) for GPUs with neither driver, each when the one
|
||||
/// before has no surface, adapter or device. A native crash in the driver can't be
|
||||
/// caught in the game; the launcher switches the setting to DirectX 12 after one at
|
||||
/// the renderer's start (#746). macOS has Metal only.
|
||||
fn backend_order(api: GraphicsApi, os: &str, wgpu_backend: bool) -> Vec<wgpu::Backends> {
|
||||
use wgpu::Backends;
|
||||
if os == "macos" || wgpu_backend {
|
||||
return vec![Backends::default()];
|
||||
}
|
||||
let windows = os == "windows";
|
||||
match api {
|
||||
GraphicsApi::Auto => vec![wgpu::Backends::VULKAN, wgpu::Backends::DX12],
|
||||
GraphicsApi::Vulkan => vec![wgpu::Backends::VULKAN],
|
||||
GraphicsApi::Dx12 => vec![wgpu::Backends::DX12],
|
||||
GraphicsApi::Auto if windows => vec![Backends::VULKAN, Backends::DX12, Backends::GL],
|
||||
GraphicsApi::Auto => vec![Backends::VULKAN, Backends::GL],
|
||||
GraphicsApi::Dx12 if windows => vec![Backends::DX12],
|
||||
GraphicsApi::Vulkan | GraphicsApi::Dx12 => vec![Backends::VULKAN],
|
||||
GraphicsApi::OpenGl => vec![Backends::GL],
|
||||
}
|
||||
}
|
||||
fn backends_name(backends: wgpu::Backends) -> &'static str {
|
||||
match backends {
|
||||
wgpu::Backends::VULKAN => "Vulkan",
|
||||
wgpu::Backends::DX12 => "DirectX 12",
|
||||
wgpu::Backends::GL => "OpenGL",
|
||||
_ => "graphics",
|
||||
}
|
||||
}
|
||||
@@ -780,7 +786,7 @@ async fn open_device(
|
||||
) -> Result<Opened> {
|
||||
let order = backend_order(
|
||||
api.with_env(),
|
||||
cfg!(windows),
|
||||
std::env::consts::OS,
|
||||
std::env::var_os("WGPU_BACKEND").is_some(),
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
@@ -793,7 +799,13 @@ async fn open_device(
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
Some(instance.create_surface(window.clone())?)
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name} surface: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
@@ -863,14 +875,14 @@ async fn open_device(
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
/// missing driver shows here rather than as a missing-DLL dialog.
|
||||
#[cfg(target_os = "windows")]
|
||||
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan or DirectX 12 found: Horizon needs a GPU \
|
||||
driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
|
||||
const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan, DirectX 12 or OpenGL found: Horizon needs \
|
||||
a GPU driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \
|
||||
start the game again";
|
||||
#[cfg(target_os = "macos")]
|
||||
const NO_GPU_DRIVER: &str = "No Metal graphics device found";
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \
|
||||
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)";
|
||||
const NO_GPU_DRIVER: &str = "No Vulkan or OpenGL graphics driver found: Horizon needs the Vulkan loader \
|
||||
(libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's), or OpenGL through EGL";
|
||||
/// Logs a step of the renderer's start, for a native crash in the graphics
|
||||
/// driver: it leaves no panic or crash report, so the log's last line is the
|
||||
/// only sign of where the game stopped. The log file is written unbuffered;
|
||||
@@ -2057,16 +2069,15 @@ impl Renderer {
|
||||
// One per blend and `.png.mcmeta` sampling (screen_effects::SAMPLE_*):
|
||||
// the shader is specialised, so a pixel pays for none of the others.
|
||||
let screen_pipelines: [wgpu::RenderPipeline; 8] = std::array::from_fn(|i| {
|
||||
let (entry, blend) = if i & 4 == 0 {
|
||||
("fs_screen", alpha_blend)
|
||||
} else {
|
||||
("fs_vignette", vignette_blend)
|
||||
let blur = i as u32 & crate::screen_effects::SAMPLE_BLUR != 0;
|
||||
let (entry, blend) = match (i & 4 == 0, blur) {
|
||||
(true, false) => ("fs_screen", alpha_blend),
|
||||
(true, true) => ("fs_screen_blur", alpha_blend),
|
||||
(false, false) => ("fs_vignette", vignette_blend),
|
||||
(false, true) => ("fs_vignette_blur", vignette_blend),
|
||||
};
|
||||
let on = |bit: u32| f64::from(u8::from(i as u32 & bit != 0));
|
||||
let constants = [
|
||||
("BLUR", on(crate::screen_effects::SAMPLE_BLUR)),
|
||||
("CLAMP", on(crate::screen_effects::SAMPLE_CLAMP)),
|
||||
];
|
||||
let clamp = i as u32 & crate::screen_effects::SAMPLE_CLAMP != 0;
|
||||
let constants = [("CLAMP", f64::from(u8::from(clamp)))];
|
||||
let compilation_options = wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
@@ -3439,6 +3450,7 @@ impl Renderer {
|
||||
translucent_crumbling_quads,
|
||||
water_mask_quads,
|
||||
shadow_quads,
|
||||
translucent_particle_quads,
|
||||
] = self.entity_batch.update(
|
||||
frame.entities,
|
||||
frame.additive,
|
||||
@@ -3450,7 +3462,8 @@ impl Renderer {
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads
|
||||
+ shadow_quads;
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
@@ -3849,7 +3862,7 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..shadow_quads * 6,
|
||||
((world_quads - shadow_quads) * 4) as i32,
|
||||
((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -3917,6 +3930,22 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
|
||||
// translucent particle layer draws after translucent terrain —
|
||||
// the quads write depth, so before it they would hide the glass
|
||||
// or water behind them.
|
||||
if translucent_particle_quads > 0 {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_particle_quads * 6,
|
||||
((world_quads - translucent_particle_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// LevelRenderer.executeClassicTransparency: translucent targets'
|
||||
// outlines follow terrain, so its blending cannot wash them out.
|
||||
if outline_edges > 0 && frame.outline_after_terrain {
|
||||
@@ -4423,14 +4452,23 @@ mod tests {
|
||||
normal: [0; 4],
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [(0, 0), (14, 9), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, id)| quad(layer, id))
|
||||
.collect();
|
||||
let entities: Vec<_> = [
|
||||
(0, 0),
|
||||
(14, 9),
|
||||
(4, 1),
|
||||
(11, 2),
|
||||
(15, 10),
|
||||
(7, 3),
|
||||
(4, 4),
|
||||
(12, 7),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, id)| quad(layer, id))
|
||||
.collect();
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 0, 1]);
|
||||
assert_eq!(counts, [4, 1, 1, 0, 1, 1]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -4438,29 +4476,31 @@ mod tests {
|
||||
.iter()
|
||||
.map(|q| q[0].light)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 3, 4, 2, 7, 9, 5, 6, 8]
|
||||
[0, 1, 3, 4, 2, 7, 9, 10, 5, 6, 8]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn windows_tries_vulkan_then_directx_12() {
|
||||
fn auto_tries_vulkan_then_directx_12_then_opengl() {
|
||||
use super::{GraphicsApi, backend_order};
|
||||
use wgpu::Backends;
|
||||
let order = |api, os| backend_order(api, os, false);
|
||||
assert_eq!(
|
||||
backend_order(GraphicsApi::Auto, true, false),
|
||||
[Backends::VULKAN, Backends::DX12]
|
||||
order(GraphicsApi::Auto, "windows"),
|
||||
[Backends::VULKAN, Backends::DX12, Backends::GL]
|
||||
);
|
||||
assert_eq!(order(GraphicsApi::Vulkan, "windows"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::Dx12, "windows"), [Backends::DX12]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "windows"), [Backends::GL]);
|
||||
assert_eq!(
|
||||
backend_order(GraphicsApi::Vulkan, true, false),
|
||||
[Backends::VULKAN]
|
||||
order(GraphicsApi::Auto, "linux"),
|
||||
[Backends::VULKAN, Backends::GL]
|
||||
);
|
||||
assert_eq!(
|
||||
backend_order(GraphicsApi::Dx12, true, false),
|
||||
[Backends::DX12]
|
||||
);
|
||||
// WGPU_BACKEND picks the backends itself; other systems have one.
|
||||
assert_eq!(order(GraphicsApi::Dx12, "linux"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "linux"), [Backends::GL]);
|
||||
// WGPU_BACKEND picks the backends itself; macOS has Metal only.
|
||||
for api in GraphicsApi::ALL {
|
||||
assert_eq!(backend_order(api, true, true), [Backends::default()]);
|
||||
assert_eq!(backend_order(api, false, false), [Backends::default()]);
|
||||
assert_eq!(backend_order(api, "windows", true), [Backends::default()]);
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
|
||||
|
||||
@@ -3,13 +3,16 @@ use super::Vertex;
|
||||
|
||||
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`) and entity shadows
|
||||
/// (14, `RenderPipelines.ENTITY_SHADOW`).
|
||||
const MATERIALS: usize = 5;
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows
|
||||
/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15,
|
||||
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`).
|
||||
const MATERIALS: usize = 6;
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
|
||||
pub const SHADOW_OVERLAY: u32 = 14;
|
||||
/// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`).
|
||||
pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EntityBatch {
|
||||
@@ -41,6 +44,7 @@ impl EntityBatch {
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
SHADOW_OVERLAY => 4,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 5,
|
||||
_ => 0,
|
||||
};
|
||||
self.buckets[material][counts[material]] = index;
|
||||
@@ -92,13 +96,14 @@ mod tests {
|
||||
) -> (Vec<Vertex>, [u32; MATERIALS]) {
|
||||
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
|
||||
let mut counts = [0; MATERIALS];
|
||||
for (i, layer) in [0, 11, 12, 13, 14].into_iter().enumerate() {
|
||||
for (i, layer) in [0, 11, 12, 13, 14, 15].into_iter().enumerate() {
|
||||
for quad in entities.as_chunks::<4>().0 {
|
||||
let material = match quad[0].layer {
|
||||
11 => 11,
|
||||
12 => 12,
|
||||
13 => 13,
|
||||
14 => 14,
|
||||
15 => 15,
|
||||
_ => 0,
|
||||
};
|
||||
if material == layer {
|
||||
@@ -118,7 +123,7 @@ mod tests {
|
||||
pos: [index as f32, 2.0, 3.0],
|
||||
uv: [index as f32, 5.0],
|
||||
layer: if mixed {
|
||||
[7, 11, 14, 12, 4, 13, 0][index / 4 % 7]
|
||||
[7, 11, 14, 12, 4, 13, 15, 0][index / 4 % 8]
|
||||
} else {
|
||||
0
|
||||
},
|
||||
|
||||
@@ -98,19 +98,27 @@ impl PresentMode {
|
||||
/// macOS). Applies at the next start.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GraphicsApi {
|
||||
/// Vulkan, and DirectX 12 when Vulkan has no adapter or device.
|
||||
/// Vulkan, then DirectX 12 (Windows), then OpenGL when neither has a surface, adapter or device.
|
||||
#[default]
|
||||
Auto,
|
||||
Vulkan,
|
||||
Dx12,
|
||||
/// OpenGL, for GPUs with no Vulkan or DirectX 12 driver (Windows and Linux).
|
||||
OpenGl,
|
||||
}
|
||||
impl GraphicsApi {
|
||||
pub const ALL: [GraphicsApi; 3] = [GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::Dx12];
|
||||
pub const ALL: [GraphicsApi; 4] = [
|
||||
GraphicsApi::Auto,
|
||||
GraphicsApi::Vulkan,
|
||||
GraphicsApi::Dx12,
|
||||
GraphicsApi::OpenGl,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GraphicsApi::Auto => "auto",
|
||||
GraphicsApi::Vulkan => "vulkan",
|
||||
GraphicsApi::Dx12 => "dx12",
|
||||
GraphicsApi::OpenGl => "opengl",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -118,6 +126,7 @@ impl GraphicsApi {
|
||||
GraphicsApi::Auto => "Auto",
|
||||
GraphicsApi::Vulkan => "Vulkan",
|
||||
GraphicsApi::Dx12 => "DirectX 12",
|
||||
GraphicsApi::OpenGl => "OpenGL",
|
||||
}
|
||||
}
|
||||
/// The API a name stands for, ignoring case; `None` for anything else.
|
||||
@@ -126,7 +135,7 @@ impl GraphicsApi {
|
||||
.into_iter()
|
||||
.find(|api| api.name().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
/// `HORIZON_GRAPHICS_API` (auto, vulkan or dx12) if set, else the setting.
|
||||
/// `HORIZON_GRAPHICS_API` (auto, vulkan, dx12 or opengl) if set, else the setting.
|
||||
pub fn with_env(self) -> Self {
|
||||
std::env::var("HORIZON_GRAPHICS_API")
|
||||
.ok()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! GUI scale is applied when drawing).
|
||||
use crate::Result;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
@@ -58,6 +58,9 @@ pub struct HudAtlas {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
|
||||
/// which puts their particles on vanilla's translucent layer.
|
||||
pub translucent: HashSet<String>,
|
||||
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
|
||||
/// sample as vanilla's defaults (nearest, repeat).
|
||||
pub flags: HashMap<String, TextureFlags>,
|
||||
@@ -462,7 +465,16 @@ impl HudAtlas {
|
||||
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
|
||||
let mut pixels = vec![0u8; (width * height * 4) as usize];
|
||||
let mut sprites = HashMap::new();
|
||||
let mut translucent = HashSet::new();
|
||||
for ((name, w, h, data), (px, py)) in images.iter().zip(placements) {
|
||||
if data
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.any(|p| p[3] != 0 && p[3] != 255)
|
||||
{
|
||||
translucent.insert(name.clone());
|
||||
}
|
||||
for row in 0..*h {
|
||||
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
|
||||
let dst = (((py + row) * width + px) * 4) as usize;
|
||||
@@ -527,6 +539,7 @@ impl HudAtlas {
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
translucent,
|
||||
flags,
|
||||
fire_animations,
|
||||
font,
|
||||
|
||||
@@ -343,7 +343,21 @@ impl Hub {
|
||||
if !window_ok || self.guests[i].spectator() {
|
||||
return Vec::new();
|
||||
}
|
||||
// A menu that is no longer valid takes no clicks.
|
||||
if let Some(m) = self.guests[i].menu.as_ref().filter(|_| in_menu)
|
||||
&& !m.slots.still_valid(world, &self.guests[i], m.pos)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let source = self.guests[i].menu.as_ref().map(|m| m.slots);
|
||||
let shulker_box = self
|
||||
.sync
|
||||
.builtin
|
||||
.get("minecraft:menu")
|
||||
.and_then(|m| m.id("minecraft:shulker_box"));
|
||||
let shulker = in_menu
|
||||
&& shulker_box.is_some()
|
||||
&& self.guests[i].menu.as_ref().map(|m| m.kind) == shulker_box;
|
||||
let mut drag = if in_menu {
|
||||
self.guests[i].menu.as_mut().and_then(|m| m.drag.take())
|
||||
} else {
|
||||
@@ -357,6 +371,7 @@ impl Hub {
|
||||
let mut view = View {
|
||||
guest,
|
||||
container: items.as_deref_mut(),
|
||||
shulker,
|
||||
};
|
||||
let (drops, changed) = menu::click(
|
||||
&mut view,
|
||||
|
||||
@@ -223,6 +223,8 @@ pub struct Guest {
|
||||
/// Whether the guest was on the ground before the first of `moves`.
|
||||
pub moves_from_ground: bool,
|
||||
pub dead: bool,
|
||||
/// `LivingEntity.deathTime`: ticks since the guest died.
|
||||
pub death_time: u32,
|
||||
/// The join packets were sent (PlayerList.placeNewPlayer ran).
|
||||
pub placed: bool,
|
||||
/// The level the player is in (`ServerPlayer.level().dimension()`): the
|
||||
@@ -349,6 +351,7 @@ impl Guest {
|
||||
moves: Vec::new(),
|
||||
moves_from_ground: false,
|
||||
dead: false,
|
||||
death_time: 0,
|
||||
placed: false,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
menu: None,
|
||||
@@ -404,6 +407,17 @@ impl Guest {
|
||||
pub fn eye_height(&self) -> f64 {
|
||||
if self.sneaking { 1.27 } else { 1.62 }
|
||||
}
|
||||
/// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest:
|
||||
/// its block interaction range is 4.5, 5 in creative.
|
||||
pub fn within_block_range(&self, pos: [i32; 3], buffer: f64) -> bool {
|
||||
let eye = [
|
||||
self.position[0],
|
||||
self.position[1] + self.eye_height(),
|
||||
self.position[2],
|
||||
];
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
within_block_range(eye, range + buffer, pos)
|
||||
}
|
||||
/// The inventory menu's 46 slots (ServerPlayer.inventoryMenu): 0 crafting
|
||||
/// result, 1..=4 crafting grid, 5..=8 armor, 9..=35 main, 36..=44 hotbar,
|
||||
/// 45 off hand.
|
||||
@@ -521,6 +535,22 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Int(i32::from(mode)),
|
||||
));
|
||||
}
|
||||
// PotionContents: the potion by its `minecraft:potion` id.
|
||||
if let Some(id) = (d.potion.as_deref()).and_then(|potion| {
|
||||
synced_id(
|
||||
"minecraft:potion",
|
||||
&horizon_world::datafix::dynamic::namespaced(potion),
|
||||
)
|
||||
}) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::POTION_CONTENTS,
|
||||
horizon_protocol::item::Component::Potion {
|
||||
potion: Some(id),
|
||||
color: None,
|
||||
rest: Vec::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
// A loaded crossbow's ammunition.
|
||||
if !d.charged.is_empty() {
|
||||
let charged = d
|
||||
@@ -568,11 +598,41 @@ fn network_stack(
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `Player.isWithinBlockInteractionRange`: `eye` closer than `range` to the
|
||||
/// box of the block at `pos` (`AABB.distanceToSqr`).
|
||||
pub fn within_block_range(eye: [f64; 3], range: f64, pos: [i32; 3]) -> bool {
|
||||
(0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< range * range
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::item::{Component, component};
|
||||
|
||||
#[test]
|
||||
fn potions_go_as_potion_registry_ids() {
|
||||
let synced_id = |registry: &str, name: &str| match (registry, name) {
|
||||
("minecraft:potion", "minecraft:swiftness") => Some(14),
|
||||
_ => None,
|
||||
};
|
||||
let potion = ItemStack::new("minecraft:splash_potion", 1).with_potion("swiftness");
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(potion)).unwrap();
|
||||
assert_eq!(
|
||||
stack.component(component::POTION_CONTENTS),
|
||||
Some(&Component::Potion {
|
||||
potion: Some(14),
|
||||
color: None,
|
||||
rest: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_go_as_synced_registry_references() {
|
||||
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
|
||||
|
||||
@@ -149,6 +149,9 @@ pub struct Hub {
|
||||
pub singleplayer_owner: Option<u128>,
|
||||
/// Who rides what, set by the game before each tick.
|
||||
pub riding: super::entity::Riding,
|
||||
/// The scoreboard's teams, set by the game each tick, for players'
|
||||
/// display names.
|
||||
pub teams: Vec<crate::scoreboard::Team>,
|
||||
last_clock: i64,
|
||||
last_rate: f32,
|
||||
ticking: (f32, bool),
|
||||
@@ -206,6 +209,24 @@ pub fn display_name(uuid: u128, name: &str) -> Tag {
|
||||
])
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
/// Player.getDisplayName: the name formatted for the player's team
|
||||
/// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the
|
||||
/// player on hover and inserting the name.
|
||||
pub fn player_display_name(&self, uuid: u128, name: &str) -> Tag {
|
||||
let Some(team) = self.teams.iter().find(|t| t.players.contains(name)) else {
|
||||
return display_name(uuid, name);
|
||||
};
|
||||
let mut c = team.formatted_name_comp(&crate::scoreboard::literal(name));
|
||||
c["click_event"] =
|
||||
serde_json::json!({"action": "suggest_command", "command": format!("/tell {name} ")});
|
||||
c["hover_event"] = serde_json::json!({"action": "show_entity", "id": "minecraft:player",
|
||||
"uuid": horizon_protocol::crypto::uuid_string(uuid), "name": name});
|
||||
c["insertion"] = name.into();
|
||||
crate::scoreboard::component_tag(&c)
|
||||
}
|
||||
}
|
||||
|
||||
/// A yellow translation of player names (`multiplayer.player.joined`).
|
||||
fn yellow_names(key: &str, names: Vec<Tag>) -> Tag {
|
||||
Tag::compound([
|
||||
@@ -315,6 +336,7 @@ impl Hub {
|
||||
entity_types: Default::default(),
|
||||
entities: Default::default(),
|
||||
entity_out: Vec::new(),
|
||||
teams: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! handle it: chat sessions validated against the services' keys, signed
|
||||
//! messages checked link by link, the acknowledged window, and messages
|
||||
//! relayed with each connection's numbering and signature cache.
|
||||
use super::{HostEvent, Hub, display_name};
|
||||
use super::{HostEvent, Hub};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
secure_chat::{self, Body, Chain, ChatSession, DecodeError, ProfileKey, Signature, Unpacked},
|
||||
@@ -214,7 +214,7 @@ impl Hub {
|
||||
}
|
||||
let bound = play::ChatTypeBound {
|
||||
chat_type: self.chat_type,
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: None,
|
||||
};
|
||||
let line = format!("<{name}> {}", message.content);
|
||||
@@ -427,7 +427,7 @@ impl Hub {
|
||||
let (uuid, name) = (self.guests[i].uuid, self.guests[i].name.clone());
|
||||
let bound = play::ChatTypeBound {
|
||||
chat_type: self.chat_type_id(chat_type),
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: None,
|
||||
};
|
||||
let line = match chat_type {
|
||||
@@ -467,15 +467,15 @@ impl Hub {
|
||||
self.chat_type_id("msg_command_incoming"),
|
||||
);
|
||||
for &t in targets {
|
||||
let target = display_name(self.guests[t].uuid, &self.guests[t].name);
|
||||
let target = self.player_display_name(self.guests[t].uuid, &self.guests[t].name);
|
||||
let out = play::ChatTypeBound {
|
||||
chat_type: outgoing,
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: Some(target),
|
||||
};
|
||||
let inc = play::ChatTypeBound {
|
||||
chat_type: incoming,
|
||||
name: display_name(uuid, &name),
|
||||
name: self.player_display_name(uuid, &name),
|
||||
target: None,
|
||||
};
|
||||
match &message {
|
||||
|
||||
@@ -64,6 +64,19 @@ impl Slots {
|
||||
.iter()
|
||||
.all(|k| world.block_entities.contains_key(k))
|
||||
}
|
||||
/// `Container.stillValid` for the guest: the block entities are there
|
||||
/// and the guest is within reach of each (`stillValidBlockEntity`). The
|
||||
/// ender chest's block is `at` (`PlayerEnderChestContainer.activeChest`).
|
||||
pub fn still_valid(self, world: &World, guest: &Guest, at: Option<(i32, i32, i32)>) -> bool {
|
||||
let blocks = match self {
|
||||
Slots::Ender => at.into_iter().collect(),
|
||||
_ => self.halves(),
|
||||
};
|
||||
self.valid(world)
|
||||
&& blocks
|
||||
.iter()
|
||||
.all(|k| guest.within_block_range([k.0, k.1, k.2], 4.0))
|
||||
}
|
||||
/// Container slot `n`.
|
||||
pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option<ItemStack> {
|
||||
if self == Slots::Ender {
|
||||
@@ -111,6 +124,8 @@ pub struct View<'a> {
|
||||
pub guest: &'a mut Guest,
|
||||
/// The container's slots, or None for the inventory menu.
|
||||
pub container: Option<&'a mut [Option<ItemStack>]>,
|
||||
/// The container is a shulker box (`ShulkerBoxSlot`).
|
||||
pub shulker: bool,
|
||||
}
|
||||
|
||||
impl View<'_> {
|
||||
@@ -153,6 +168,12 @@ impl View<'_> {
|
||||
if self.container.is_none() && (5..=8).contains(&i) {
|
||||
return rules.armor_index(&stack.item) == Some(i - 5);
|
||||
}
|
||||
if self.shulker
|
||||
&& self.container.as_ref().is_some_and(|c| i < c.len())
|
||||
&& !crate::menu::fits_inside_container_items(stack)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.enabled(i)
|
||||
}
|
||||
fn slot_max(&self, i: usize, stack: &ItemStack, rules: &dyn Rules) -> u32 {
|
||||
@@ -665,6 +686,24 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// The prepared resources' items, or None (the test is skipped) without them.
|
||||
fn rules() -> Option<ItemRules> {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping resource-backed menu check: {}", root.display());
|
||||
return None;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
Some(ItemRules(Items::load(&root, ®istry, &facts).unwrap()))
|
||||
}
|
||||
|
||||
fn contents(stack: &Option<ItemStack>) -> Vec<(String, u32)> {
|
||||
let inside = stack.as_ref().and_then(|s| s.data());
|
||||
inside.map_or(Vec::new(), |d| {
|
||||
@@ -680,20 +719,9 @@ mod tests {
|
||||
/// slot; a stack clicked onto a bundle goes in, as far as it fits.
|
||||
#[test]
|
||||
fn guest_chest_clicks_fill_and_empty_bundles() {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping resource-backed bundle check: {}", root.display());
|
||||
let Some(rules) = rules() else {
|
||||
return;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
let rules = ItemRules(Items::load(&root, ®istry, &facts).unwrap());
|
||||
};
|
||||
let mut g = guest();
|
||||
let mut chest = vec![
|
||||
Some(ItemStack::new("minecraft:stone", 48)),
|
||||
@@ -704,6 +732,7 @@ mod tests {
|
||||
let mut view = View {
|
||||
guest: &mut g,
|
||||
container: Some(&mut chest),
|
||||
shulker: false,
|
||||
};
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:bundle", 1));
|
||||
// Left click with the bundle: the stone goes in.
|
||||
@@ -736,4 +765,37 @@ mod tests {
|
||||
);
|
||||
assert_eq!(contents(&view.get(2)), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guest_shulker_box_menu_refuses_shulker_boxes() {
|
||||
let Some(rules) = rules() else {
|
||||
return;
|
||||
};
|
||||
for shulker in [true, false] {
|
||||
let mut g = guest();
|
||||
let mut container = vec![None; 27];
|
||||
let mut drag = None;
|
||||
let mut view = View {
|
||||
guest: &mut g,
|
||||
container: Some(&mut container),
|
||||
shulker,
|
||||
};
|
||||
// Carried onto an empty slot, shift-clicked from the hotbar
|
||||
// (menu slot 27 + 27) and swapped in with the number key 1.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:red_shulker_box", 1));
|
||||
click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules);
|
||||
assert_eq!(view.get(0).is_none(), shulker);
|
||||
view.guest.carried = None;
|
||||
view.guest.inventory[0] = Some(ItemStack::new("minecraft:shulker_box", 1));
|
||||
click(&mut view, &mut drag, 54, 0, input::QUICK_MOVE, &rules);
|
||||
assert_eq!(view.guest.inventory[0].is_some(), shulker);
|
||||
view.guest.inventory[0] = Some(ItemStack::new("minecraft:blue_shulker_box", 1));
|
||||
click(&mut view, &mut drag, 5, 0, input::SWAP, &rules);
|
||||
assert_eq!(view.get(5).is_none(), shulker);
|
||||
// Anything else goes in.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:diamond", 1));
|
||||
click(&mut view, &mut drag, 9, 0, input::PICKUP, &rules);
|
||||
assert!(view.get(9).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,12 @@ pub fn beacon_required_levels(effect: Option<&str>) -> i32 {
|
||||
.map_or(i32::MAX, |i| i as i32 + 1)
|
||||
}
|
||||
|
||||
/// `Item.canFitInsideContainerItems` (`ShulkerBoxSlot.mayPlace`): shulker
|
||||
/// boxes cannot nest.
|
||||
pub fn fits_inside_container_items(stack: &ItemStack) -> bool {
|
||||
!stack.item.ends_with("shulker_box")
|
||||
}
|
||||
|
||||
/// The containers a menu spans.
|
||||
const PLAYER: usize = 0;
|
||||
const MAIN: usize = 1;
|
||||
@@ -1210,11 +1216,6 @@ impl Menu {
|
||||
env.items.get(&stack.item).is_some_and(|i| i.fuel.is_some())
|
||||
}
|
||||
|
||||
/// `Item.canFitInsideContainerItems`: shulker boxes cannot nest.
|
||||
fn fits_inside(stack: &ItemStack) -> bool {
|
||||
!(stack.item.ends_with("shulker_box"))
|
||||
}
|
||||
|
||||
// ---- Slot ----
|
||||
|
||||
fn may_place(&self, env: &Env, s: usize, stack: &ItemStack) -> bool {
|
||||
@@ -1247,7 +1248,7 @@ impl Menu {
|
||||
.as_deref()
|
||||
.is_some_and(|m| i.equip_allowed.as_ref().is_none_or(|a| a.contains(m)))
|
||||
}),
|
||||
Role::Shulker => Self::fits_inside(stack),
|
||||
Role::Shulker => fits_inside_container_items(stack),
|
||||
Role::Fuel => Self::is_fuel(env, stack) || Self::is_bucket(stack),
|
||||
Role::Output | Role::CraftResult | Role::Inert => false,
|
||||
Role::CrafterIn => self.data.get(self.slots[s].index) != Some(&1),
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct Installed {
|
||||
pub problem: Option<String>,
|
||||
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
|
||||
pub dev: bool,
|
||||
/// The running mod set a page of widgets (capability `ui`): its row
|
||||
/// gains a Settings button.
|
||||
pub has_settings: bool,
|
||||
}
|
||||
|
||||
impl ModList {
|
||||
@@ -96,6 +99,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev,
|
||||
has_settings: false,
|
||||
};
|
||||
let mut list = ModList {
|
||||
installed: vec![installed("a", false), installed("b", true)],
|
||||
|
||||
@@ -1083,11 +1083,26 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
/// What `applyEffectsFromBlocks` does to a server's player along the
|
||||
/// moves its client sent since `from` (in its history): the damage of
|
||||
/// magma, cactus, lit campfires and grown sweet berry bushes. Its client
|
||||
/// moves it, so the slowing and the rest are the client's.
|
||||
/// magma, cactus, lit campfires and grown sweet berry bushes, and powder
|
||||
/// snow's FREEZE, then `LivingEntity.aiStep`'s freezing and its damage.
|
||||
/// Its client moves it, so the slowing and the rest are the client's.
|
||||
pub fn server_block_damage(&mut self, from: Vec3) -> Result<()> {
|
||||
self.player.old_position = from;
|
||||
self.effects_from_blocks(true)
|
||||
// Entity.baseTick
|
||||
self.player.was_in_powder_snow = self.player.in_powder_snow;
|
||||
self.player.in_powder_snow = false;
|
||||
self.effects_from_blocks(true)?;
|
||||
// LivingEntity.aiStep "freezing" (server).
|
||||
if !self.player.in_powder_snow || !self.player.can_freeze() {
|
||||
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
|
||||
}
|
||||
if self.player.health.tick_count % 40 == 0
|
||||
&& self.player.is_fully_frozen()
|
||||
&& self.player.can_freeze()
|
||||
{
|
||||
self.freeze_damage()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn effects_from_blocks(&mut self, damage_only: bool) -> Result<()> {
|
||||
use horizon_world::entity_move::MovementRecord;
|
||||
@@ -1191,7 +1206,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
groups += 1;
|
||||
last_step = step;
|
||||
}
|
||||
if groups == 1 {
|
||||
if groups == 1 && !damage_only {
|
||||
// A later group finds the fire already out.
|
||||
self.player.powder_snow_entered.push(p);
|
||||
}
|
||||
@@ -1316,6 +1331,8 @@ impl MotionWorld<'_, '_, '_> {
|
||||
"minecraft:sweet_berry_bush" if damage_only => {
|
||||
self.sweet_berry_bush_damage(state)?;
|
||||
}
|
||||
// PowderSnowBlock.entityInside's FREEZE.
|
||||
"minecraft:powder_snow" if damage_only => entered.push((step, p)),
|
||||
_ => {}
|
||||
}
|
||||
if damage_only {
|
||||
|
||||
@@ -893,6 +893,9 @@ enum Act {
|
||||
ResetPadKey(&'static str),
|
||||
ResetPadKeys,
|
||||
ToggleMod(usize),
|
||||
/// The installed mod's own settings page (capability `ui`), shown as a
|
||||
/// screen: `Event::ModSettings` has the game open it.
|
||||
ModSettings(usize),
|
||||
OpenModFolder,
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
@@ -944,6 +947,9 @@ pub enum Event {
|
||||
Packs(Vec<String>),
|
||||
OpenPackFolder,
|
||||
OpenModFolder,
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
@@ -1503,15 +1509,26 @@ impl OptionsUi {
|
||||
} else if installed.dev {
|
||||
label = label.append(" (Dev)");
|
||||
}
|
||||
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
|
||||
Row::Buttons(
|
||||
if installed.dev || installed.id.is_empty() {
|
||||
inactive(widget)
|
||||
} else {
|
||||
widget
|
||||
},
|
||||
None,
|
||||
)
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
// `ui`) gets its own button beside the toggle.
|
||||
if installed.has_settings {
|
||||
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
|
||||
let settings = button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Settings"),
|
||||
Act::ModSettings(i),
|
||||
);
|
||||
Row::Buttons(
|
||||
if dead { inactive(toggle) } else { toggle },
|
||||
Some(settings),
|
||||
)
|
||||
} else {
|
||||
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i));
|
||||
Row::Buttons(if dead { inactive(widget) } else { widget }, None)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -2498,6 +2515,10 @@ impl OptionsUi {
|
||||
s.mods.toggle(i);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
events.push(Event::ButtonClick);
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
@@ -2988,6 +3009,7 @@ mod tests {
|
||||
description: "May show HUD panels.".into(),
|
||||
problem: id.is_empty().then(|| "Invalid package".into()),
|
||||
dev,
|
||||
has_settings: false,
|
||||
});
|
||||
}
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
|
||||
@@ -18,7 +18,10 @@ use horizon_core::{
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
macro_rules! particle_types {
|
||||
($($variant:ident = $name:literal),* $(,)?) => {
|
||||
@@ -196,6 +199,35 @@ impl ParticleType {
|
||||
| Self::OminousSpawning
|
||||
)
|
||||
}
|
||||
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
|
||||
/// hardcodes the translucent layer, drawn after translucent terrain.
|
||||
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
|
||||
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
|
||||
/// sprite set on the engine.
|
||||
pub fn translucent_layer(self) -> bool {
|
||||
match self {
|
||||
// CampfireSmokeParticle.
|
||||
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
|
||||
// PlayerCloudParticle.
|
||||
| Self::Cloud | Self::Sneeze
|
||||
// SpellParticle.
|
||||
| Self::Effect | Self::InstantEffect | Self::EntityEffect
|
||||
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
|
||||
// EmissiveRisingParticle.
|
||||
| Self::Soul | Self::SculkSoul
|
||||
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
|
||||
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
|
||||
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
|
||||
| Self::Vibration | Self::Firefly | Self::Flash
|
||||
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
|
||||
// SparkParticle (firework), SquidInkParticle, TotemParticle.
|
||||
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
|
||||
| Self::TotemOfUndying
|
||||
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
|
||||
| Self::VaultConnection => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -1147,7 +1179,9 @@ pub struct ParticleEngine {
|
||||
pub requested: Option<Vec<(ParticleOptions, [f64; 3], [f64; 3])>>,
|
||||
names: Vec<String>,
|
||||
rects: Vec<Option<Rect>>,
|
||||
atlas: HashMap<String, Rect>,
|
||||
/// Each named sprite, and whether `SingleQuadParticle.Layer.bySprite` puts
|
||||
/// it on the translucent layer (a texel with 0 < alpha < 255).
|
||||
atlas: HashMap<String, (Rect, bool)>,
|
||||
sets: HashMap<ParticleType, u32>,
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
@@ -1167,7 +1201,7 @@ pub struct ParticleEngine {
|
||||
/// `ParticleGroup.MAX_PARTICLES` (per render group; one group here).
|
||||
const MAX_PARTICLES: usize = 16384;
|
||||
impl ParticleEngine {
|
||||
pub fn new(root: &Path, atlas: &HashMap<String, Rect>) -> Self {
|
||||
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
|
||||
let mut engine = Self {
|
||||
particles: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
@@ -1178,7 +1212,10 @@ impl ParticleEngine {
|
||||
requested: None,
|
||||
names: Vec::new(),
|
||||
rects: Vec::new(),
|
||||
atlas: atlas.clone(),
|
||||
atlas: atlas
|
||||
.iter()
|
||||
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
|
||||
.collect(),
|
||||
sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
emitters: Vec::new(),
|
||||
@@ -1220,7 +1257,7 @@ impl ParticleEngine {
|
||||
return i as u32;
|
||||
}
|
||||
self.names.push(name.to_owned());
|
||||
self.rects.push(self.atlas.get(name).copied());
|
||||
self.rects.push(self.atlas.get(name).map(|&(rect, _)| rect));
|
||||
(self.names.len() - 1) as u32
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -3760,9 +3797,18 @@ impl ParticleEngine {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let rect = match &p.sprite {
|
||||
Sprite::Index(i) => self.rects.get(*i as usize).copied().flatten(),
|
||||
Sprite::Named(name) => self.atlas.get(name).copied(),
|
||||
// `SubmitNodeCollection.submitQuadParticleGroup`: the translucent
|
||||
// layer draws after translucent terrain, so a crumb of stained
|
||||
// glass cannot hide the glass behind it (`getLayer`).
|
||||
let (rect, layer) = match &p.sprite {
|
||||
Sprite::Index(i) => (
|
||||
self.rects.get(*i as usize).copied().flatten(),
|
||||
p.ty.translucent_layer(),
|
||||
),
|
||||
Sprite::Named(name) => match self.atlas.get(name) {
|
||||
Some(&(rect, translucent)) => (Some(rect), translucent),
|
||||
None => (None, false),
|
||||
},
|
||||
};
|
||||
let Some(r) = rect else { continue };
|
||||
let a = f64::from(partial);
|
||||
@@ -3867,7 +3913,11 @@ impl ParticleEngine {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
layer: if layer {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
},
|
||||
color,
|
||||
light,
|
||||
});
|
||||
@@ -3891,7 +3941,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(0, 0, 0);
|
||||
// Destination offset (-15, 7, 15); the unused top byte is ignored.
|
||||
engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, [16, 8, 16], 0);
|
||||
@@ -3916,7 +3966,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn consume_teleport_unpacks_offsets_within_127() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(0, 0, 0);
|
||||
// TeleportRandomlyConsumeEffect: BlockUtil.clampedPackDifferenceInPosition
|
||||
// of the move (-7, -3, 9) with radii 127, from (4, 12, -5) to (-3, 9, 4).
|
||||
@@ -3941,7 +3991,8 @@ mod tests {
|
||||
(2, 0, 99_413_408_083_437),
|
||||
] {
|
||||
for distant in [false, true] {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new());
|
||||
let mut engine =
|
||||
ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(0, 0, 0);
|
||||
if distant {
|
||||
engine.camera = [1000.0; 3];
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
// atlas, wrapped as repeat reads it (hud.rs), so the hardware's bilinear
|
||||
// sample is GL_LINEAR with repeat; clamp-to-edge keeps the sample point half
|
||||
// a texel inside. Each sampling has its own pipeline (gfx.rs), so a pixel
|
||||
// costs what it did with nearest and repeat only: one sample.
|
||||
// costs what it did with nearest and repeat only: one sample. Each entry
|
||||
// point reads `atlas` through one sampler only: GLSL (wgpu's OpenGL backend)
|
||||
// combines a texture with its sampler, and an override would not remove the
|
||||
// other sampler's branch.
|
||||
@group(0) @binding(2) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var linear_sampler: sampler;
|
||||
override BLUR: bool = false;
|
||||
override CLAMP: bool = false;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@@ -32,28 +34,36 @@ struct Out {
|
||||
out.edges = vec4(rect.xy + half, rect.zw - half);
|
||||
return out;
|
||||
}
|
||||
fn sample(in: Out) -> vec4<f32> {
|
||||
// The sprite's atlas coordinates of this pixel.
|
||||
fn atlas_uv(in: Out) -> vec2<f32> {
|
||||
var f = in.uv;
|
||||
if !CLAMP {
|
||||
// Repeat: one period of the sprite.
|
||||
f = fract(f);
|
||||
}
|
||||
var uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
|
||||
if CLAMP {
|
||||
uv = clamp(uv, in.edges.xy, in.edges.zw);
|
||||
return clamp(uv, in.edges.xy, in.edges.zw);
|
||||
}
|
||||
if BLUR {
|
||||
return textureSampleLevel(atlas, linear_sampler, uv, 0.0) * in.color;
|
||||
}
|
||||
return textureSampleLevel(atlas, nearest_sampler, uv, 0.0) * in.color;
|
||||
return uv;
|
||||
}
|
||||
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = sample(in);
|
||||
fn screen(c: vec4<f32>) -> vec4<f32> {
|
||||
if c.a <= 0.0 { discard; }
|
||||
return c;
|
||||
}
|
||||
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
|
||||
return screen(textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color);
|
||||
}
|
||||
@fragment fn fs_screen_blur(in: Out) -> @location(0) vec4<f32> {
|
||||
return screen(textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color);
|
||||
}
|
||||
// RenderPipelines.VIGNETTE blends ZERO, ONE_MINUS_SRC_COLOR: output the
|
||||
// texture colour; the blend state darkens the frame by it.
|
||||
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
|
||||
return vec4(sample(in).rgb, 1.0);
|
||||
let c = textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color;
|
||||
return vec4(c.rgb, 1.0);
|
||||
}
|
||||
@fragment fn fs_vignette_blur(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color;
|
||||
return vec4(c.rgb, 1.0);
|
||||
}
|
||||
@@ -23,7 +23,8 @@ impl Zip {
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
Self::from_bytes(fs::read(path)?)
|
||||
}
|
||||
pub fn from_bytes(data: Vec<u8>) -> io::Result<Self> {
|
||||
pub fn from_bytes(mut data: Vec<u8>) -> io::Result<Self> {
|
||||
normalize_end(&mut data);
|
||||
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
|
||||
let entries = (0..archive.len())
|
||||
.filter_map(|i| Some((archive.name_for_index(i)?.replace('\\', "/"), i)))
|
||||
@@ -59,6 +60,59 @@ impl Zip {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 22 fixed bytes of the end-of-central-directory record.
|
||||
const END_HEADER: usize = 22;
|
||||
|
||||
/// Java's `ZipFile` ignores the END record's disk numbers and file counts,
|
||||
/// reading the central directory from `cenpos` to `cenpos + cenlen` whatever
|
||||
/// they say; the `zip` crate insists the record agrees with the directory.
|
||||
/// Packs written while streaming (Nexo serves them for gommehd.net, for one)
|
||||
/// leave every file's local header a stub and the END counts and disk number
|
||||
/// at 0 and 0xFFFF: archives Java opens but the crate refuses. Count the real
|
||||
/// directory entries and normalize the record so it does not look multi-disk
|
||||
/// or empty.
|
||||
fn normalize_end(data: &mut [u8]) {
|
||||
if data.len() < END_HEADER {
|
||||
return;
|
||||
}
|
||||
let u16_at = |data: &[u8], at: usize| u16::from_le_bytes(data[at..at + 2].try_into().unwrap());
|
||||
let u32_at = |data: &[u8], at: usize| u32::from_le_bytes(data[at..at + 4].try_into().unwrap());
|
||||
// The END record's comment can hide the signature: scan back through the
|
||||
// last 64 KiB, like every zip reader.
|
||||
let start = data.len().saturating_sub(END_HEADER + u16::MAX as usize);
|
||||
let mut end = None;
|
||||
for pos in (start..=data.len().saturating_sub(END_HEADER)).rev() {
|
||||
if data[pos..pos + 4] == [0x50, 0x4b, 0x05, 0x06]
|
||||
&& pos + END_HEADER + u16_at(data, pos + 20) as usize <= data.len()
|
||||
{
|
||||
end = Some(pos);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(end) = end else { return };
|
||||
// The directory's real position and length, as Java derives them; the
|
||||
// recorded offset is relative to the archive's start.
|
||||
let cenlen = u32_at(data, end + 12) as usize;
|
||||
let cenpos = end.saturating_sub(cenlen);
|
||||
let mut count = 0u16;
|
||||
let mut p = cenpos;
|
||||
while p + 46 <= end && data[p..p + 4] == [0x50, 0x4b, 0x01, 0x02] {
|
||||
count = count.saturating_add(1);
|
||||
p += 46
|
||||
+ u16_at(data, p + 28) as usize
|
||||
+ u16_at(data, p + 30) as usize
|
||||
+ u16_at(data, p + 32) as usize;
|
||||
}
|
||||
let disk = u16_at(data, end + 4);
|
||||
let with_directory = u16_at(data, end + 6);
|
||||
if disk == with_directory && u16_at(data, end + 8) == count && u16_at(data, end + 10) == count {
|
||||
return;
|
||||
}
|
||||
data[end + 4..end + 10].fill(0);
|
||||
data[end + 8..end + 10].copy_from_slice(&count.to_le_bytes());
|
||||
data[end + 10..end + 12].copy_from_slice(&count.to_le_bytes());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -207,4 +261,61 @@ mod tests {
|
||||
cut.truncate(cut.len() / 2);
|
||||
assert!(Zip::from_bytes(cut).is_err());
|
||||
}
|
||||
|
||||
/// A pack as a streaming writer serves it (Nexo/gommehd.net): every
|
||||
/// local header is a 30-byte stub and the END record's disk number and
|
||||
/// counts are wrong. Java 25's `ZipFile` opens it, with each size either
|
||||
/// in the directory or as 0xFFFFFFFF plus its Zip64 extra field (without
|
||||
/// that field it refuses the archive: "invalid zip64 extra len size").
|
||||
#[test]
|
||||
fn reads_a_streamed_archive() {
|
||||
for zip64 in [false, true] {
|
||||
let files: [(&str, &[u8]); 2] = [
|
||||
("pack.mcmeta", b"{\"pack\":{}}"),
|
||||
("a/b.txt", b"streamed entry"),
|
||||
];
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, body) in files {
|
||||
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
|
||||
let offset = out.len() as u32;
|
||||
out.extend(0x0403_4b50u32.to_le_bytes());
|
||||
out.extend([0; 26]);
|
||||
out.extend(&data);
|
||||
let extra: Vec<u8> = if zip64 {
|
||||
[1u16, 8]
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes())
|
||||
.chain((body.len() as u64).to_le_bytes())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
central.extend(0x0201_4b50u32.to_le_bytes());
|
||||
central.extend([20, 0, 20, 0, 0, 0]);
|
||||
central.extend(8u16.to_le_bytes());
|
||||
central.extend([0; 8]);
|
||||
central.extend((data.len() as u32).to_le_bytes());
|
||||
central.extend(if zip64 { u32::MAX } else { body.len() as u32 }.to_le_bytes());
|
||||
central.extend((name.len() as u16).to_le_bytes());
|
||||
central.extend((extra.len() as u16).to_le_bytes());
|
||||
central.extend([0; 10]);
|
||||
central.extend(offset.to_le_bytes());
|
||||
central.extend(name.as_bytes());
|
||||
central.extend(extra);
|
||||
}
|
||||
let start = out.len() as u32;
|
||||
out.extend(¢ral);
|
||||
out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
out.extend(u16::MAX.to_le_bytes());
|
||||
out.extend([0; 6]);
|
||||
out.extend((central.len() as u32).to_le_bytes());
|
||||
out.extend(start.to_le_bytes());
|
||||
out.extend([0; 2]);
|
||||
let zip = Zip::from_bytes(out).unwrap();
|
||||
assert_eq!(zip.names().collect::<Vec<_>>(), ["a/b.txt", "pack.mcmeta"]);
|
||||
assert_eq!(zip.read("pack.mcmeta").unwrap(), b"{\"pack\":{}}");
|
||||
assert_eq!(zip.read("a/b.txt").unwrap(), b"streamed entry");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,18 +21,24 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Every interface but `log` is a capability: list the ones the mod uses in
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`).
|
||||
//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `ui`).
|
||||
//!
|
||||
//! Widgets (capability `ui`): a mod shows a screen it owns with
|
||||
//! [`ui::open_screen`] and a page in Options > Mods with
|
||||
//! [`ui::set_settings`]; what the player does with them comes back as
|
||||
//! [`Mod::ui_select`], [`Mod::ui_closed`] and [`Mod::ui_settings_opened`].
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "wit",
|
||||
world: "horizon-mod",
|
||||
world: "horizon-ui-mod",
|
||||
pub_export_macro: true,
|
||||
export_macro_name: "__export_horizon_mod",
|
||||
default_bindings_module: "horizon_mod_sdk",
|
||||
});
|
||||
|
||||
pub use exports::horizon::mods::events::{Guest as __Guest, Registration};
|
||||
pub use horizon::mods::{chat, game, hud, log, storage};
|
||||
pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest;
|
||||
pub use horizon::mods::{chat, game, hud, log, storage, ui};
|
||||
|
||||
/// The game's calls into a mod. Only the hooks [`Registration`] asks for
|
||||
/// are called; the others may stay as their defaults.
|
||||
@@ -51,6 +57,14 @@ pub trait Mod: Sized + 'static {
|
||||
}
|
||||
/// A chat line the player received, when [`Registration::chat`] is set.
|
||||
fn chat(&mut self, _text: &str) {}
|
||||
/// A row of one of the mod's `list` widgets was picked (capability
|
||||
/// `ui`): the widget's `id` and the row.
|
||||
fn ui_select(&mut self, _id: u32, _row: u32) {}
|
||||
/// The player left the mod's screen (Esc or Done; capability `ui`).
|
||||
fn ui_closed(&mut self) {}
|
||||
/// The player opened the mod's settings page (capability `ui`): call
|
||||
/// [`ui::set_settings`] for fresh widgets.
|
||||
fn ui_settings_opened(&mut self) {}
|
||||
}
|
||||
|
||||
impl Registration {
|
||||
@@ -135,13 +149,27 @@ macro_rules! export_mod {
|
||||
with(|m| $crate::Mod::chat(m, &text))
|
||||
}
|
||||
}
|
||||
impl $crate::__UiEventsGuest for Export {
|
||||
fn select(id: u32, row: u32) {
|
||||
with(|m| $crate::Mod::ui_select(m, id, row))
|
||||
}
|
||||
fn closed() {
|
||||
with(|m| $crate::Mod::ui_closed(m))
|
||||
}
|
||||
fn settings_opened() {
|
||||
with(|m| $crate::Mod::ui_settings_opened(m))
|
||||
}
|
||||
}
|
||||
// Only WebAssembly has the component's exports: a host build (cargo test or
|
||||
// clippy on the workspace) checks the mod without them, which macOS
|
||||
// couldn't link (its exported-symbols list cuts names at the `#`).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
$crate::__export_horizon_mod!(Export);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = <Export as $crate::__Guest>::init;
|
||||
let _ = (
|
||||
<Export as $crate::__Guest>::init,
|
||||
<Export as $crate::__UiEventsGuest>::select,
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -86,6 +86,83 @@ interface storage {
|
||||
delete: func(key: string);
|
||||
}
|
||||
|
||||
/// Capability `ui`: widgets the game draws with its own menus' widgets, for
|
||||
/// a screen the mod owns and a settings page in Options > Mods. Layout and
|
||||
/// drawing are the game's: a mod says what its widgets hold, never where
|
||||
/// they sit or how they look, and the game clips everything it is handed.
|
||||
interface ui {
|
||||
/// A line of text.
|
||||
record text-widget {
|
||||
/// The mod's own name for the widget; `ui-events` calls echo it.
|
||||
id: u32,
|
||||
/// Up to 256 characters.
|
||||
text: string,
|
||||
/// 0xRRGGBB; 0 is the menus' white.
|
||||
color: u32,
|
||||
}
|
||||
/// An item's icon and a name beside it, as recipe viewers show them.
|
||||
record item-widget {
|
||||
/// The mod's own name for the widget.
|
||||
id: u32,
|
||||
/// `minecraft:diamond`, `<mod>:<path>`; an unknown id draws a "?".
|
||||
item: string,
|
||||
/// The name shown beside the icon (empty: the item's own).
|
||||
name: string,
|
||||
}
|
||||
/// Rows the player picks one of, as an options page lists choices: a
|
||||
/// pick is `ui-events::select`, `selected` is the row to mark now.
|
||||
record list-widget {
|
||||
/// The mod's own name for the widget; `ui-events::select` echoes it.
|
||||
id: u32,
|
||||
/// The rows' texts, at most 512, each up to 256 characters.
|
||||
rows: list<string>,
|
||||
/// The row to mark as the current choice, if any.
|
||||
selected: option<u32>,
|
||||
}
|
||||
/// A thing on a mod's screen or settings page.
|
||||
variant widget {
|
||||
/// Text the player reads.
|
||||
text(text-widget),
|
||||
/// An item's icon and name.
|
||||
item(item-widget),
|
||||
/// Rows the player picks from.
|
||||
%list(list-widget),
|
||||
}
|
||||
/// A screen the mod owns: a title over widgets stacked down the middle,
|
||||
/// the Done button the game adds, and Esc to leave it.
|
||||
record screen {
|
||||
/// Its title, up to 256 characters.
|
||||
title: string,
|
||||
/// Its widgets, in order, at most 64.
|
||||
widgets: list<widget>,
|
||||
}
|
||||
/// Show `screen` over the game; a second call while it is open replaces
|
||||
/// it. Only the last call of one hook counts.
|
||||
open-screen: func(screen: screen);
|
||||
/// Close the mod's screen when it is open (the mod hears no `closed`
|
||||
/// for this: it was its own doing).
|
||||
close-screen: func();
|
||||
/// The mod's page in Options > Mods, reached from a settings button on
|
||||
/// its row: the widgets it shows, an empty list for no page. The player
|
||||
/// opening the page is `ui-events::settings-opened`: push fresh widgets
|
||||
/// then; later calls update the page while it shows.
|
||||
set-settings: func(widgets: list<widget>);
|
||||
}
|
||||
|
||||
/// What the game calls on a mod that shows widgets (capability `ui`):
|
||||
/// what the player did with them.
|
||||
interface ui-events {
|
||||
/// A row of one of the mod's `list` widgets was picked: the widget's
|
||||
/// `id` and the row, on the screen or the settings page that showed it.
|
||||
select: func(id: u32, row: u32);
|
||||
/// The player left the mod's screen (Esc or Done); not for a screen the
|
||||
/// mod closed itself or one that went away with the mod.
|
||||
closed: func();
|
||||
/// The player opened the mod's settings page in Options > Mods: call
|
||||
/// `ui::set-settings` now to fill it with fresh widgets.
|
||||
settings-opened: func();
|
||||
}
|
||||
|
||||
/// What the game calls. Only the hooks `init` asks for are ever called.
|
||||
interface events {
|
||||
record registration {
|
||||
@@ -119,3 +196,18 @@ world horizon-mod {
|
||||
import storage;
|
||||
export events;
|
||||
}
|
||||
|
||||
/// A mod with widgets (capability `ui`): the SDK builds every mod against
|
||||
/// this world. Mods built for `horizon-mod` alone (no `ui-events` export)
|
||||
/// still load and simply hear nothing of their widgets.
|
||||
world horizon-ui-mod {
|
||||
include horizon-mod;
|
||||
import ui;
|
||||
export ui-events;
|
||||
}
|
||||
|
||||
/// The game's side of `horizon-ui-mod` beyond `horizon-mod`.
|
||||
world ui-host {
|
||||
import ui;
|
||||
export ui-events;
|
||||
}
|
||||
@@ -49,6 +49,12 @@ pub struct Manifest {
|
||||
/// that are on may share one (`content::clash`).
|
||||
#[serde(default)]
|
||||
pub namespaces: Vec<String>,
|
||||
/// The experimental capabilities the mod opts into (`["ui"]`): each one
|
||||
/// it lists in `capabilities` must be named here too, since it may change
|
||||
/// in any release (docs/api/stability.md). One no longer experimental is
|
||||
/// accepted here, so the manifest keeps loading once it is promoted.
|
||||
#[serde(default)]
|
||||
pub experimental: Vec<Capability>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
@@ -75,14 +81,18 @@ pub enum Capability {
|
||||
/// New blocks and items from the mod's `data/` (API 0.2): no WIT
|
||||
/// interface, the game reads them before the world loads.
|
||||
Registries,
|
||||
/// A screen the mod owns and a settings page in Options > Mods, drawn
|
||||
/// with the menus' own widgets (interface `ui`, calls `ui-events`).
|
||||
Ui,
|
||||
}
|
||||
impl Capability {
|
||||
pub const ALL: [Capability; 5] = [
|
||||
pub const ALL: [Capability; 6] = [
|
||||
Capability::GameRead,
|
||||
Capability::Chat,
|
||||
Capability::Hud,
|
||||
Capability::Storage,
|
||||
Capability::Registries,
|
||||
Capability::Ui,
|
||||
];
|
||||
/// The WIT interface it grants, in `horizon:mods`.
|
||||
pub fn interface(self) -> &'static str {
|
||||
@@ -92,8 +102,14 @@ impl Capability {
|
||||
Capability::Hud => "hud",
|
||||
Capability::Storage => "storage",
|
||||
Capability::Registries => "registries",
|
||||
Capability::Ui => "ui",
|
||||
}
|
||||
}
|
||||
/// May change in any release: a mod uses it only with
|
||||
/// `experimental = ["<name>"]` in its manifest.
|
||||
pub fn is_experimental(self) -> bool {
|
||||
matches!(self, Capability::Ui)
|
||||
}
|
||||
/// What the player reads before turning the mod on: "May <this>".
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
@@ -102,6 +118,7 @@ impl Capability {
|
||||
Capability::Hud => "draw text on your screen",
|
||||
Capability::Storage => "save its own settings to disk",
|
||||
Capability::Registries => "add blocks and items to your worlds",
|
||||
Capability::Ui => "show its own screens and a settings page",
|
||||
}
|
||||
}
|
||||
/// The capability a component import needs, `Ok(None)` for one every
|
||||
@@ -132,6 +149,7 @@ impl fmt::Display for Capability {
|
||||
Capability::Hud => "hud",
|
||||
Capability::Storage => "storage",
|
||||
Capability::Registries => "registries",
|
||||
Capability::Ui => "ui",
|
||||
};
|
||||
f.write_str(name)
|
||||
}
|
||||
@@ -162,6 +180,16 @@ impl Manifest {
|
||||
"{MANIFEST}: capability registries needs api = \"0.2\""
|
||||
));
|
||||
}
|
||||
if let Some(c) = m
|
||||
.capabilities
|
||||
.iter()
|
||||
.find(|c| c.is_experimental() && !m.experimental.contains(c))
|
||||
{
|
||||
return Err(format!(
|
||||
"{MANIFEST}: capability {c} is experimental and may change in any release: \
|
||||
add experimental = [\"{c}\"] to use it"
|
||||
));
|
||||
}
|
||||
if !m.namespaces.is_empty() && !m.capabilities.contains(&Capability::Registries) {
|
||||
return Err(format!(
|
||||
"{MANIFEST}: namespaces are for content, which needs capability registries"
|
||||
@@ -414,4 +442,21 @@ capabilities = ["game-read", "hud"]
|
||||
assert_eq!(p.wasm.as_deref(), Some(&b"\0asm"[..]));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn experimental_capabilities_need_the_opt_in() {
|
||||
let base = "id = \"m\"\nname = \"M\"\nversion = \"1\"\napi = \"0.1\"\n";
|
||||
let err = Manifest::parse(&format!("{base}capabilities = [\"ui\"]\n")).unwrap_err();
|
||||
assert!(err.contains("experimental = [\"ui\"]"), "{err}");
|
||||
let m = Manifest::parse(&format!(
|
||||
"{base}capabilities = [\"ui\"]\nexperimental = [\"ui\"]\n"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(m.experimental, [Capability::Ui]);
|
||||
// A capability that is (or became) stable is fine in the list.
|
||||
Manifest::parse(&format!(
|
||||
"{base}capabilities = [\"hud\"]\nexperimental = [\"hud\"]\n"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,15 @@ mod bindings {
|
||||
world: "horizon-mod",
|
||||
});
|
||||
}
|
||||
/// Capability `ui`'s world beyond `horizon-mod` (docs/mods.md): a second
|
||||
/// world, so mods built before it (no `ui-events` export) still instantiate
|
||||
/// as `horizon-mod` and simply hear nothing of their widgets.
|
||||
mod ui_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../horizon-mod-sdk/wit",
|
||||
world: "ui-host",
|
||||
});
|
||||
}
|
||||
pub use bindings::horizon::mods::{
|
||||
game::{Clock, Player},
|
||||
hud::{Corner, Line},
|
||||
@@ -25,6 +34,12 @@ use bindings::{
|
||||
exports::horizon::mods::events::Registration,
|
||||
horizon::mods::{chat, game, hud, log as mod_log, storage},
|
||||
};
|
||||
/// The widget model a `ui` screen or settings page shows (WIT `interface
|
||||
/// ui`), for the game to draw with its menus' own widgets.
|
||||
pub use ui_bindings::horizon::mods::ui::{
|
||||
ItemWidget, ListWidget, Screen as UiScreen, TextWidget, Widget as UiWidget,
|
||||
};
|
||||
use ui_bindings::{UiHost, horizon::mods::ui};
|
||||
|
||||
/// A mod's linear memory cap.
|
||||
const MEMORY: usize = 64 << 20;
|
||||
@@ -66,6 +81,11 @@ pub enum Output {
|
||||
Feedback { text: String, error: bool },
|
||||
/// The mod was turned off: it trapped, overran or failed to start.
|
||||
Stopped { name: String, reason: String },
|
||||
/// `ui.open-screen`: the mod's entry in `entries()`; what it shows is
|
||||
/// [`Mods::screen`].
|
||||
OpenScreen { owner: usize },
|
||||
/// `ui.close-screen`: the mod closed its own screen (no `closed` back).
|
||||
CloseScreen { owner: usize },
|
||||
}
|
||||
|
||||
/// A mod's text panel, drawn every frame until it changes.
|
||||
@@ -95,19 +115,44 @@ pub struct Entry {
|
||||
run: Option<Running>,
|
||||
modified: Option<SystemTime>,
|
||||
}
|
||||
impl Entry {
|
||||
/// The running mod has a settings page (capability `ui` set widgets
|
||||
/// with `ui.set-settings`): its row in Options > Mods gains a button.
|
||||
pub fn has_ui_settings(&self) -> bool {
|
||||
self.run
|
||||
.as_ref()
|
||||
.is_some_and(|r| !r.store.data().ui.settings.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
struct Running {
|
||||
store: Store<State>,
|
||||
exports: HorizonMod,
|
||||
/// The mod's `ui-events` export; none for a mod built before `ui`.
|
||||
ui: Option<UiHost>,
|
||||
hooks: Registration,
|
||||
}
|
||||
|
||||
/// What capability `ui` pushed since the hooks last ran.
|
||||
#[derive(Default)]
|
||||
struct UiState {
|
||||
/// The screen `ui.open-screen` last pushed, until `ui.close-screen`.
|
||||
screen: Option<UiScreen>,
|
||||
/// The widgets `ui.set-settings` last pushed (the mod's page in
|
||||
/// Options > Mods).
|
||||
settings: Vec<UiWidget>,
|
||||
/// The calls since the last hook ended, for [`Output`].
|
||||
open: bool,
|
||||
close: bool,
|
||||
}
|
||||
|
||||
struct State {
|
||||
name: String,
|
||||
id: String,
|
||||
snapshot: Arc<Snapshot>,
|
||||
out: Vec<Output>,
|
||||
panel: Option<Panel>,
|
||||
ui: UiState,
|
||||
/// Capability `storage`'s folder; made on first `set`.
|
||||
data_dir: PathBuf,
|
||||
/// Bytes `storage::set` may still write before this hook call ends
|
||||
@@ -184,6 +229,8 @@ impl Mods {
|
||||
let mut linker = Linker::new(&engine);
|
||||
HorizonMod::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
UiHost::add_to_linker::<State, HasSelf<State>>(&mut linker, |s| s)
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.engine = Some((engine, linker));
|
||||
}
|
||||
Ok(self.engine.as_ref().expect("made above"))
|
||||
@@ -242,6 +289,7 @@ impl Mods {
|
||||
snapshot,
|
||||
out: Vec::new(),
|
||||
panel: None,
|
||||
ui: UiState::default(),
|
||||
data_dir,
|
||||
storage_budget: STORAGE_WRITE_BUDGET,
|
||||
limits: StoreLimitsBuilder::new()
|
||||
@@ -252,8 +300,13 @@ impl Mods {
|
||||
let mut store = Store::new(engine, state);
|
||||
store.limiter(|s| &mut s.limits);
|
||||
store.set_fuel(FUEL_INIT).map_err(|e| e.to_string())?;
|
||||
let exports =
|
||||
HorizonMod::instantiate(&mut store, &component, linker).map_err(|e| reason(&e))?;
|
||||
let instance = linker
|
||||
.instantiate(&mut store, &component)
|
||||
.map_err(|e| reason(&e))?;
|
||||
let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?;
|
||||
// `ui-events` is a mod built since `ui`'s; older mods have no export
|
||||
// and stay callable through `events` alone.
|
||||
let ui = UiHost::new(&mut store, &instance).ok();
|
||||
let mut hooks = exports
|
||||
.horizon_mods_events()
|
||||
.call_init(&mut store)
|
||||
@@ -269,6 +322,7 @@ impl Mods {
|
||||
Ok(Some(Running {
|
||||
store,
|
||||
exports,
|
||||
ui,
|
||||
hooks,
|
||||
}))
|
||||
}
|
||||
@@ -290,7 +344,7 @@ impl Mods {
|
||||
&mut self,
|
||||
fuel: u64,
|
||||
wants: impl Fn(&Registration) -> bool,
|
||||
mut f: impl FnMut(&HorizonMod, &mut Store<State>) -> wasmtime::Result<()>,
|
||||
mut f: impl FnMut(&mut Running) -> wasmtime::Result<()>,
|
||||
) {
|
||||
for i in 0..self.entries.len() {
|
||||
if self.entries[i]
|
||||
@@ -308,20 +362,28 @@ impl Mods {
|
||||
&mut self,
|
||||
i: usize,
|
||||
fuel: u64,
|
||||
f: impl FnOnce(&HorizonMod, &mut Store<State>) -> wasmtime::Result<R>,
|
||||
f: impl FnOnce(&mut Running) -> wasmtime::Result<R>,
|
||||
) -> Option<R> {
|
||||
let entry = &mut self.entries[i];
|
||||
let run = entry.run.as_mut()?;
|
||||
run.store.data_mut().snapshot = self.snapshot.clone();
|
||||
run.store.data_mut().storage_budget = STORAGE_WRITE_BUDGET;
|
||||
let started = Instant::now();
|
||||
let result = run
|
||||
.store
|
||||
.set_fuel(fuel)
|
||||
.and_then(|()| f(&run.exports, &mut run.store));
|
||||
let result = run.store.set_fuel(fuel).and_then(|()| f(run));
|
||||
// An average over about the last 64 calls.
|
||||
entry.cost = (entry.cost * 63 + started.elapsed()) / 64;
|
||||
self.out.append(&mut run.store.data_mut().out);
|
||||
let data = run.store.data_mut();
|
||||
let (open, close) = (
|
||||
std::mem::take(&mut data.ui.open),
|
||||
std::mem::take(&mut data.ui.close),
|
||||
);
|
||||
self.out.append(&mut data.out);
|
||||
if open {
|
||||
self.out.push(Output::OpenScreen { owner: i });
|
||||
}
|
||||
if close {
|
||||
self.out.push(Output::CloseScreen { owner: i });
|
||||
}
|
||||
match result {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
@@ -344,7 +406,11 @@ impl Mods {
|
||||
self.each(
|
||||
FUEL_TICK,
|
||||
|h| h.tick,
|
||||
|m, s| m.horizon_mods_events().call_tick(s, tick),
|
||||
|run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_tick(&mut run.store, tick)
|
||||
},
|
||||
);
|
||||
}
|
||||
/// A rendered frame, `seconds` after the last.
|
||||
@@ -352,7 +418,11 @@ impl Mods {
|
||||
self.each(
|
||||
FUEL_FRAME,
|
||||
|h| h.frame,
|
||||
|m, s| m.horizon_mods_events().call_frame(s, seconds),
|
||||
|run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_frame(&mut run.store, seconds)
|
||||
},
|
||||
);
|
||||
}
|
||||
/// A chat line the player received, as plain text.
|
||||
@@ -360,7 +430,11 @@ impl Mods {
|
||||
self.each(
|
||||
FUEL_EVENT,
|
||||
|h| h.chat,
|
||||
|m, s| m.horizon_mods_events().call_chat(s, text),
|
||||
|run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_chat(&mut run.store, text)
|
||||
},
|
||||
);
|
||||
}
|
||||
/// Whether a running mod owns `/name`.
|
||||
@@ -381,8 +455,10 @@ impl Mods {
|
||||
let Some(i) = self.command_owner(name) else {
|
||||
return false;
|
||||
};
|
||||
let result = self.call(i, FUEL_EVENT, |m, s| {
|
||||
m.horizon_mods_events().call_command(s, name, args)
|
||||
let result = self.call(i, FUEL_EVENT, |run| {
|
||||
run.exports
|
||||
.horizon_mods_events()
|
||||
.call_command(&mut run.store, name, args)
|
||||
});
|
||||
match result {
|
||||
Some(Ok(Some(text))) => self.out.push(Output::Feedback { text, error: false }),
|
||||
@@ -405,6 +481,60 @@ impl Mods {
|
||||
.iter()
|
||||
.filter_map(|e| e.run.as_ref()?.store.data().panel.as_ref())
|
||||
}
|
||||
/// The screen mod `i` last pushed with `ui.open-screen`, while it is up.
|
||||
pub fn screen(&self, i: usize) -> Option<&UiScreen> {
|
||||
self.entries
|
||||
.get(i)?
|
||||
.run
|
||||
.as_ref()?
|
||||
.store
|
||||
.data()
|
||||
.ui
|
||||
.screen
|
||||
.as_ref()
|
||||
}
|
||||
/// The widgets `ui.set-settings` pushed: mod `i`'s page in
|
||||
/// Options > Mods. Empty while it has none.
|
||||
pub fn settings_widgets(&self, i: usize) -> &[UiWidget] {
|
||||
self.entries
|
||||
.get(i)
|
||||
.and_then(|e| e.run.as_ref())
|
||||
.map_or(&[], |r| &r.store.data().ui.settings)
|
||||
}
|
||||
/// A `list` widget's row was picked on a surface of mod `i`.
|
||||
pub fn ui_select(&mut self, i: usize, id: u32, row: u32) {
|
||||
self.ui_event(i, |ui, s| {
|
||||
ui.horizon_mods_ui_events().call_select(s, id, row)
|
||||
});
|
||||
}
|
||||
/// The player left mod `i`'s screen (Esc or Done).
|
||||
pub fn ui_closed(&mut self, i: usize) {
|
||||
self.ui_event(i, |ui, s| ui.horizon_mods_ui_events().call_closed(s));
|
||||
}
|
||||
/// The player opened mod `i`'s settings page.
|
||||
pub fn ui_settings_opened(&mut self, i: usize) {
|
||||
self.ui_event(i, |ui, s| {
|
||||
ui.horizon_mods_ui_events().call_settings_opened(s)
|
||||
});
|
||||
}
|
||||
/// A `ui-events` call on mod `i`; no export, no call (mods built
|
||||
/// before `ui` have none).
|
||||
fn ui_event(
|
||||
&mut self,
|
||||
i: usize,
|
||||
f: impl FnOnce(&UiHost, &mut Store<State>) -> wasmtime::Result<()>,
|
||||
) {
|
||||
let has_ui = self
|
||||
.entries
|
||||
.get(i)
|
||||
.and_then(|e| e.run.as_ref())
|
||||
.is_some_and(|r| r.ui.is_some());
|
||||
if has_ui {
|
||||
self.call(i, FUEL_EVENT, |run| {
|
||||
f(run.ui.as_ref().expect("checked above"), &mut run.store)
|
||||
});
|
||||
}
|
||||
}
|
||||
/// What mods asked to show since the last call.
|
||||
pub fn drain(&mut self) -> Vec<Output> {
|
||||
std::mem::take(&mut self.out)
|
||||
@@ -476,6 +606,33 @@ fn clip(text: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Widgets on a `ui` screen or settings page, and rows on one `list`.
|
||||
const WIDGETS: usize = 64;
|
||||
const LIST_ROWS: usize = 512;
|
||||
|
||||
fn clip_widgets(widgets: Vec<UiWidget>) -> Vec<UiWidget> {
|
||||
widgets
|
||||
.into_iter()
|
||||
.take(WIDGETS)
|
||||
.map(|w| match w {
|
||||
UiWidget::Text(mut t) => {
|
||||
t.text = clip(t.text);
|
||||
UiWidget::Text(t)
|
||||
}
|
||||
UiWidget::Item(mut i) => {
|
||||
i.item = clip(i.item);
|
||||
i.name = clip(i.name);
|
||||
UiWidget::Item(i)
|
||||
}
|
||||
UiWidget::List(mut l) => {
|
||||
l.rows = l.rows.into_iter().take(LIST_ROWS).map(clip).collect();
|
||||
l.selected = l.selected.filter(|&s| (s as usize) < l.rows.len());
|
||||
UiWidget::List(l)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl mod_log::Host for State {
|
||||
fn write(&mut self, level: mod_log::Level, message: String) {
|
||||
let level = match level {
|
||||
@@ -527,6 +684,21 @@ impl hud::Host for State {
|
||||
self.panel = None;
|
||||
}
|
||||
}
|
||||
impl ui::Host for State {
|
||||
fn open_screen(&mut self, mut screen: ui::Screen) {
|
||||
screen.title = clip(screen.title);
|
||||
screen.widgets = clip_widgets(screen.widgets);
|
||||
self.ui.screen = Some(screen);
|
||||
self.ui.open = true;
|
||||
}
|
||||
fn close_screen(&mut self) {
|
||||
self.ui.screen = None;
|
||||
self.ui.close = true;
|
||||
}
|
||||
fn set_settings(&mut self, widgets: Vec<ui::Widget>) {
|
||||
self.ui.settings = clip_widgets(widgets);
|
||||
}
|
||||
}
|
||||
impl storage::Host for State {
|
||||
fn get(&mut self, key: String) -> Option<Vec<u8>> {
|
||||
storage_key_ok(&key)
|
||||
|
||||
@@ -17,6 +17,14 @@ version = "0.1.0"
|
||||
api = "0.1"
|
||||
capabilities = ["chat", "storage"]
|
||||
"#;
|
||||
const UI_MANIFEST: &str = r#"
|
||||
id = "test_ui_guest"
|
||||
name = "Test UI Guest"
|
||||
version = "0.1.0"
|
||||
api = "0.1"
|
||||
capabilities = ["chat", "ui"]
|
||||
experimental = ["ui"]
|
||||
"#;
|
||||
|
||||
/// Builds both mods once and writes their `.hmod` files; returns the folder.
|
||||
fn mods_dir() -> &'static Path {
|
||||
@@ -32,6 +40,8 @@ fn mods_dir() -> &'static Path {
|
||||
"coords-hud",
|
||||
"-p",
|
||||
"horizon-mods-test-guest",
|
||||
"-p",
|
||||
"horizon-mods-test-ui-guest",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
@@ -48,6 +58,7 @@ fn mods_dir() -> &'static Path {
|
||||
let mods = [
|
||||
("coords_hud", std::fs::read_to_string(example).unwrap()),
|
||||
("horizon_mods_test_guest", GUEST_MANIFEST.to_string()),
|
||||
("horizon_mods_test_ui_guest", UI_MANIFEST.to_string()),
|
||||
];
|
||||
for (name, manifest) in mods {
|
||||
let core = std::fs::read(built.join(format!("{name}.wasm"))).unwrap();
|
||||
@@ -366,6 +377,45 @@ fn hook_overhead() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_screens_settings_and_events() {
|
||||
let mut mods = load("horizon_mods_test_ui_guest.hmod");
|
||||
assert_eq!(mods.entries()[0].status, Status::Running);
|
||||
assert!(!mods.entries()[0].has_ui_settings());
|
||||
// `/ui_screen` pushes the screen; the host clips nothing of this one.
|
||||
assert!(mods.command("ui_screen"));
|
||||
assert_eq!(mods.drain(), [Output::OpenScreen { owner: 0 }]);
|
||||
let screen = mods.screen(0).expect("a pushed screen");
|
||||
assert_eq!(screen.title, "Guest's screen");
|
||||
assert_eq!(screen.widgets.len(), 3);
|
||||
// What the player did comes back through `ui-events`.
|
||||
mods.ui_select(0, 7, 2);
|
||||
mods.ui_closed(0);
|
||||
assert_eq!(
|
||||
mods.drain(),
|
||||
[
|
||||
Output::Chat {
|
||||
name: "Test UI Guest".into(),
|
||||
text: "selected 7:2".into(),
|
||||
},
|
||||
Output::Chat {
|
||||
name: "Test UI Guest".into(),
|
||||
text: "closed".into(),
|
||||
},
|
||||
]
|
||||
);
|
||||
// The settings page: empty until the mod fills it, refreshed on open.
|
||||
assert!(mods.command("ui_settings"));
|
||||
assert_eq!(mods.settings_widgets(0).len(), 3);
|
||||
assert!(mods.entries()[0].has_ui_settings());
|
||||
mods.ui_settings_opened(0);
|
||||
assert_eq!(mods.settings_widgets(0).len(), 1);
|
||||
// `/ui_close` closes the mod's own screen.
|
||||
assert!(mods.command("ui_close"));
|
||||
assert!(mods.drain().contains(&Output::CloseScreen { owner: 0 }));
|
||||
assert!(mods.screen(0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_only_mods_load_without_an_engine() {
|
||||
let manifest = r#"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "horizon-mods-test-ui-guest"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "A mod that shows widgets, for horizon-mods' sandbox tests"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
horizon-mod-sdk = { path = "../../../horizon-mod-sdk" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,66 @@
|
||||
//! A mod for the runtime's `ui` tests: `/ui_screen` opens a screen with one
|
||||
//! of each widget, `/ui_settings` sets a settings page, `/ui_close` closes
|
||||
//! the screen; the `ui-events` calls answer in chat so the host sees them.
|
||||
use horizon_mod_sdk::{
|
||||
Mod, Registration, chat,
|
||||
ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget},
|
||||
};
|
||||
|
||||
struct Guest;
|
||||
|
||||
fn widgets() -> Vec<Widget> {
|
||||
vec![
|
||||
Widget::Text(TextWidget {
|
||||
id: 1,
|
||||
text: "Pick a row".into(),
|
||||
color: 0,
|
||||
}),
|
||||
Widget::Item(ItemWidget {
|
||||
id: 2,
|
||||
item: "minecraft:diamond".into(),
|
||||
name: String::new(),
|
||||
}),
|
||||
Widget::List(ListWidget {
|
||||
id: 7,
|
||||
rows: vec!["first".into(), "second".into(), "third".into()],
|
||||
selected: Some(1),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
impl Mod for Guest {
|
||||
fn init() -> Result<(Self, Registration), String> {
|
||||
let hooks = ["ui_screen", "ui_settings", "ui_close"]
|
||||
.into_iter()
|
||||
.fold(Registration::new(), Registration::command);
|
||||
Ok((Guest, hooks))
|
||||
}
|
||||
fn command(&mut self, name: &str, _args: &str) -> Result<Option<String>, String> {
|
||||
match name {
|
||||
"ui_screen" => ui::open_screen(&Screen {
|
||||
title: "Guest's screen".into(),
|
||||
widgets: widgets(),
|
||||
}),
|
||||
"ui_settings" => ui::set_settings(&widgets()),
|
||||
"ui_close" => ui::close_screen(),
|
||||
_ => {}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
fn ui_select(&mut self, id: u32, row: u32) {
|
||||
chat::show(&format!("selected {id}:{row}"));
|
||||
}
|
||||
fn ui_closed(&mut self) {
|
||||
chat::show("closed");
|
||||
}
|
||||
fn ui_settings_opened(&mut self) {
|
||||
ui::set_settings(&[Widget::Text(TextWidget {
|
||||
id: 1,
|
||||
text: "fresh".into(),
|
||||
color: 0x33CC33,
|
||||
})]);
|
||||
chat::show("settings opened");
|
||||
}
|
||||
}
|
||||
|
||||
horizon_mod_sdk::export_mod!(Guest);
|
||||
@@ -35,6 +35,7 @@ pub mod component {
|
||||
pub const MAP_ID: i32 = 48;
|
||||
pub const MAP_POST_PROCESSING: i32 = 50;
|
||||
pub const CHARGED_PROJECTILES: i32 = 51;
|
||||
pub const POTION_CONTENTS: i32 = 53;
|
||||
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
|
||||
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
|
||||
pub const TRIM: i32 = 58;
|
||||
@@ -65,6 +66,13 @@ pub enum Component {
|
||||
Lore(Vec<Tag>),
|
||||
Enchantments(Vec<(i32, i32)>),
|
||||
AttackRange([f32; 6]),
|
||||
/// `PotionContents`: the potion's `minecraft:potion` id and the custom
|
||||
/// colour; the custom effects and name stay their encoded bytes.
|
||||
Potion {
|
||||
potion: Option<i32>,
|
||||
color: Option<i32>,
|
||||
rest: Vec<u8>,
|
||||
},
|
||||
Profile(Profile),
|
||||
CustomModelData {
|
||||
floats: Vec<f32>,
|
||||
@@ -162,6 +170,22 @@ impl Component {
|
||||
.collect::<Result<_>>()?,
|
||||
)
|
||||
}
|
||||
POTION_CONTENTS => {
|
||||
let potion = if r.bool()? { Some(r.varint()?) } else { None };
|
||||
let color = if r.bool()? { Some(r.i32()?) } else { None };
|
||||
let start = r.position();
|
||||
skip::potion_effects_and_name(r)?;
|
||||
// No custom effects and no name: an empty tail.
|
||||
let rest = match r.since(start) {
|
||||
[0, 0] => Vec::new(),
|
||||
bytes => bytes.to_vec(),
|
||||
};
|
||||
Self::Potion {
|
||||
potion,
|
||||
color,
|
||||
rest,
|
||||
}
|
||||
}
|
||||
ATTACK_RANGE => {
|
||||
let mut values = [0.0f32; 6];
|
||||
for v in &mut values {
|
||||
@@ -283,6 +307,27 @@ impl Component {
|
||||
}
|
||||
}
|
||||
Self::AttackRange(values) => values.iter().for_each(|v| w.f32(*v)),
|
||||
Self::Potion {
|
||||
potion,
|
||||
color,
|
||||
rest,
|
||||
} => {
|
||||
w.bool(potion.is_some());
|
||||
if let Some(id) = potion {
|
||||
w.varint(*id);
|
||||
}
|
||||
w.bool(color.is_some());
|
||||
if let Some(color) = color {
|
||||
w.i32(*color);
|
||||
}
|
||||
// An empty tail is no custom effects and no name.
|
||||
if rest.is_empty() {
|
||||
w.varint(0);
|
||||
w.bool(false);
|
||||
} else {
|
||||
w.bytes(rest);
|
||||
}
|
||||
}
|
||||
Self::Profile(p) => {
|
||||
w.u8(0);
|
||||
w.bool(p.name.is_some());
|
||||
@@ -532,6 +577,51 @@ mod tests {
|
||||
assert_eq!(r.remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn potion_contents_round_trip() {
|
||||
let mut arrow = Stack::new(7, 3);
|
||||
arrow.added.push((
|
||||
component::POTION_CONTENTS,
|
||||
Component::Potion {
|
||||
potion: Some(31),
|
||||
color: None,
|
||||
rest: Vec::new(),
|
||||
},
|
||||
));
|
||||
round::<Option<Stack>, Slot>(Some(arrow));
|
||||
// A custom colour, one custom effect (speed II for 600 ticks, shown,
|
||||
// no hidden effect) and a custom name survive as they came.
|
||||
let mut w = Writer::new();
|
||||
w.bool(false);
|
||||
w.bool(true);
|
||||
w.i32(0x00FF_8800);
|
||||
w.varint(1);
|
||||
for v in [1, 1, 600] {
|
||||
w.varint(v);
|
||||
}
|
||||
for b in [false, true, true, false] {
|
||||
w.bool(b);
|
||||
}
|
||||
w.bool(true);
|
||||
w.string("Tea");
|
||||
let mut r = Reader::new(&w.data);
|
||||
let read = Component::read(component::POTION_CONTENTS, &mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0);
|
||||
let Component::Potion {
|
||||
potion,
|
||||
color,
|
||||
rest,
|
||||
} = &read
|
||||
else {
|
||||
panic!("{read:?}");
|
||||
};
|
||||
assert_eq!((*potion, *color), (None, Some(0x00FF_8800)));
|
||||
assert!(!rest.is_empty());
|
||||
let mut again = Writer::new();
|
||||
read.write(component::POTION_CONTENTS, &mut again);
|
||||
assert_eq!(again.data, w.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_round_trip() {
|
||||
round::<Option<Stack>, Slot>(None);
|
||||
|
||||
@@ -87,7 +87,6 @@ pub(super) fn component(kind: i32, r: &mut Reader) -> Result<()> {
|
||||
}
|
||||
// BundleContents: templates.
|
||||
52 => list(r, template),
|
||||
53 => potion_contents(r),
|
||||
// SuspiciousStewEffects: effect, duration.
|
||||
55 => list(r, |r| {
|
||||
varint(r)?;
|
||||
@@ -410,10 +409,9 @@ fn kinetic_weapon(r: &mut Reader) -> Result<()> {
|
||||
optional(r, sound)?;
|
||||
optional(r, sound)
|
||||
}
|
||||
/// PotionContents: potion, customColor, customEffects, customName.
|
||||
fn potion_contents(r: &mut Reader) -> Result<()> {
|
||||
optional(r, varint)?;
|
||||
optional(r, i32)?;
|
||||
/// PotionContents' customEffects and customName (`Component::read` reads
|
||||
/// the potion and colour before them).
|
||||
pub(super) fn potion_effects_and_name(r: &mut Reader) -> Result<()> {
|
||||
list(r, mob_effect)?;
|
||||
optional(r, string)
|
||||
}
|
||||
|
||||
@@ -849,7 +849,17 @@ mod tests {
|
||||
let first = lock(&dir).unwrap();
|
||||
assert!(lock(&dir).unwrap_err().contains("already running"));
|
||||
drop(first);
|
||||
lock(&dir).unwrap();
|
||||
// A process another test spawns holds a copy of the lock's descriptor between its fork and exec:
|
||||
// the lock comes back once that child has exec'd.
|
||||
let mut again = lock(&dir);
|
||||
for _ in 0..50 {
|
||||
if again.is_ok() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
again = lock(&dir);
|
||||
}
|
||||
again.unwrap();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ use horizon_core::{
|
||||
pub use library::MaterialRuleLibrary;
|
||||
pub use noise::MaterialNoiseSources;
|
||||
use rule::CompiledRule;
|
||||
pub use rule::SurfaceVariants;
|
||||
pub use rule::{MaterialRule, MaterialRuleError};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
@@ -299,7 +300,81 @@ enum Kind<R: RandomSource> {
|
||||
},
|
||||
Not(Box<Compiled<R>>),
|
||||
}
|
||||
/// What a specialised rule may assume about every position it is applied to
|
||||
/// (`CompiledRule::specialize`): its y lies in `low..=high`, and whether it is
|
||||
/// at or above the column's preliminary surface level, when known.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Facts {
|
||||
pub low: i32,
|
||||
pub high: i32,
|
||||
pub above_surface: Option<bool>,
|
||||
}
|
||||
impl<R: RandomSource> Clone for Kind<R>
|
||||
where
|
||||
R::Positional: Clone,
|
||||
{
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
Self::Simple(condition) => Self::Simple(condition.clone()),
|
||||
Self::Biome(names) => Self::Biome(names.clone()),
|
||||
Self::Constant(value) => Self::Constant(*value),
|
||||
Self::Noise { sampler, min, max } => Self::Noise {
|
||||
sampler: *sampler,
|
||||
min: *min,
|
||||
max: *max,
|
||||
},
|
||||
Self::Gradient { low, high, random } => Self::Gradient {
|
||||
low: *low,
|
||||
high: *high,
|
||||
random: random.clone(),
|
||||
},
|
||||
Self::Not(child) => Self::Not(child.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<R: RandomSource> Clone for Compiled<R>
|
||||
where
|
||||
R::Positional: Clone,
|
||||
{
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
kind: self.kind.clone(),
|
||||
revision: self.revision,
|
||||
result: self.result,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<R: RandomSource> Compiled<R> {
|
||||
/// The value `facts` decide for this condition at every position they
|
||||
/// cover. Conditions read the position and the world, never change them,
|
||||
/// so one decided in advance gives what testing it would.
|
||||
fn decided(&self, facts: Facts) -> Option<bool> {
|
||||
match &self.kind {
|
||||
Kind::Constant(value) => Some(*value),
|
||||
Kind::Simple(MaterialCondition::AbovePreliminarySurface) => facts.above_surface,
|
||||
Kind::Gradient { low, high, .. } => {
|
||||
if facts.high <= *low {
|
||||
Some(true)
|
||||
} else if facts.low >= *high {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Kind::Not(child) => child.decided(facts).map(|value| !value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The vertical gradients' bounds, and whether the preliminary surface
|
||||
/// is asked for.
|
||||
fn inputs(&self, gradients: &mut Vec<(i32, i32)>, surface: &mut bool) {
|
||||
match &self.kind {
|
||||
Kind::Gradient { low, high, .. } => gradients.push((*low, *high)),
|
||||
Kind::Simple(MaterialCondition::AbovePreliminarySurface) => *surface = true,
|
||||
Kind::Not(child) => child.inputs(gradients, surface),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn constant_result(&self) -> Option<bool> {
|
||||
match &self.kind {
|
||||
Kind::Constant(value) => Some(*value),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Compiled material rule composition, sharing the condition worker's context.
|
||||
use super::{Compiled, Context, MaterialCondition, MaterialConditions, MaterialSurfaceQueries};
|
||||
use super::{
|
||||
Compiled, Context, Facts, MaterialCondition, MaterialConditions, MaterialSurfaceQueries,
|
||||
};
|
||||
use crate::{registry::BlockStateId, worldgen::DecodeError};
|
||||
use horizon_core::{
|
||||
density::DensitySampler,
|
||||
@@ -209,6 +211,161 @@ impl<R: RandomSource> CompiledRule<R> {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<R: RandomSource> CompiledRule<R>
|
||||
where
|
||||
R::Positional: Clone,
|
||||
{
|
||||
/// This rule where `facts` hold: conditions they decide are folded away,
|
||||
/// with what can no longer produce a block, and a sequence stops at its
|
||||
/// first child that always produces one. For every position the facts
|
||||
/// cover it gives the block the whole rule gives.
|
||||
fn specialize(&self, facts: Facts) -> Self {
|
||||
match self {
|
||||
Self::Empty => Self::Empty,
|
||||
Self::Block(block) => Self::Block(*block),
|
||||
Self::Bandlands => Self::Bandlands,
|
||||
Self::OreVein {
|
||||
blocks,
|
||||
raw_ore_chance,
|
||||
densities,
|
||||
random,
|
||||
} => Self::OreVein {
|
||||
blocks: *blocks,
|
||||
raw_ore_chance: *raw_ore_chance,
|
||||
densities: *densities,
|
||||
random: random.clone(),
|
||||
},
|
||||
Self::Condition { condition, rule } => match condition.decided(facts) {
|
||||
Some(false) => Self::Empty,
|
||||
Some(true) => rule.specialize(facts),
|
||||
None => match rule.specialize(facts) {
|
||||
Self::Empty => Self::Empty,
|
||||
rule => Self::Condition {
|
||||
condition: condition.clone(),
|
||||
rule: Box::new(rule),
|
||||
},
|
||||
},
|
||||
},
|
||||
Self::Sequence(children) => {
|
||||
let mut result = Vec::new();
|
||||
for child in children {
|
||||
let child = child.specialize(facts);
|
||||
if matches!(child, Self::Empty) {
|
||||
continue;
|
||||
}
|
||||
let last = child.always_produces();
|
||||
result.push(child);
|
||||
if last {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match result.len() {
|
||||
0 => Self::Empty,
|
||||
1 => result.pop().unwrap(),
|
||||
_ => Self::Sequence(result),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether applying this rule never gives `None` (only an error).
|
||||
fn always_produces(&self) -> bool {
|
||||
match self {
|
||||
Self::Block(_) | Self::Bandlands => true,
|
||||
Self::Sequence(children) => children.iter().any(Self::always_produces),
|
||||
Self::Empty | Self::OreVein { .. } | Self::Condition { .. } => false,
|
||||
}
|
||||
}
|
||||
fn inputs(&self, gradients: &mut Vec<(i32, i32)>, surface: &mut bool) {
|
||||
match self {
|
||||
Self::Condition { condition, rule } => {
|
||||
condition.inputs(gradients, surface);
|
||||
rule.inputs(gradients, surface);
|
||||
}
|
||||
Self::Sequence(children) => {
|
||||
for child in children {
|
||||
child.inputs(gradients, surface);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A rule specialised (`CompiledRule::specialize`) for each band of y between
|
||||
/// its vertical gradients' bounds, and below and at or above the preliminary
|
||||
/// surface when the rule asks for it: most blocks of a column, deep stone,
|
||||
/// then evaluate only the few rules that can apply there.
|
||||
pub struct SurfaceVariants {
|
||||
/// The first y of each band after the first, ascending.
|
||||
starts: Vec<i32>,
|
||||
/// The rule index per band (two per band, below then above the surface,
|
||||
/// when `surface`).
|
||||
rules: Vec<usize>,
|
||||
pub surface: bool,
|
||||
}
|
||||
impl SurfaceVariants {
|
||||
/// The rule for a block at `y`, `above` the preliminary surface (ignored
|
||||
/// unless `surface`).
|
||||
#[inline]
|
||||
pub fn rule(&self, y: i32, above: bool) -> usize {
|
||||
let band = self.starts.partition_point(|&start| start <= y);
|
||||
if self.surface {
|
||||
self.rules[band * 2 + usize::from(above)]
|
||||
} else {
|
||||
self.rules[band]
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S>
|
||||
where
|
||||
R::Positional: Clone,
|
||||
{
|
||||
/// Add the specialised variants of rule `index` (`SurfaceVariants`).
|
||||
pub fn surface_variants(&mut self, index: usize) -> Result<SurfaceVariants, MaterialRuleError> {
|
||||
let rule = self
|
||||
.rules
|
||||
.get(index)
|
||||
.ok_or(MaterialRuleError::UnknownRule)?;
|
||||
let (mut gradients, mut surface) = (Vec::new(), false);
|
||||
rule.inputs(&mut gradients, &mut surface);
|
||||
// A gradient is true at and below `low`, random between, false at
|
||||
// and above `high`: it is decided within each band these bound.
|
||||
let mut starts: Vec<i32> = gradients
|
||||
.iter()
|
||||
.flat_map(|&(low, high)| [low.saturating_add(1), high])
|
||||
.collect();
|
||||
starts.sort_unstable();
|
||||
starts.dedup();
|
||||
let mut variants = Vec::new();
|
||||
for band in 0..=starts.len() {
|
||||
let low = if band == 0 {
|
||||
i32::MIN
|
||||
} else {
|
||||
starts[band - 1]
|
||||
};
|
||||
let high = starts.get(band).map_or(i32::MAX, |&next| next - 1);
|
||||
let sides: &[Option<bool>] = if surface {
|
||||
&[Some(false), Some(true)]
|
||||
} else {
|
||||
&[None]
|
||||
};
|
||||
for &above_surface in sides {
|
||||
let facts = Facts {
|
||||
low,
|
||||
high,
|
||||
above_surface,
|
||||
};
|
||||
variants.push(self.rules[index].specialize(facts));
|
||||
}
|
||||
}
|
||||
let first = self.rules.len();
|
||||
self.rules.extend(variants);
|
||||
Ok(SurfaceVariants {
|
||||
starts,
|
||||
rules: (first..self.rules.len()).collect(),
|
||||
surface,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
|
||||
/// Compile all children in order, even after an unconditional block rule.
|
||||
/// Failed compilation never adds a partial rule to this worker.
|
||||
|
||||
@@ -196,7 +196,10 @@ pub fn build_surface<R: RandomSource, S: MaterialSurfaceQueries, B: TerrainBlock
|
||||
rule: MaterialRule,
|
||||
inputs: SurfaceInputs<'_, R, S>,
|
||||
extensions: &MaterialExtensions<R::Positional>,
|
||||
) -> Result<(), SurfaceError> {
|
||||
) -> Result<(), SurfaceError>
|
||||
where
|
||||
R::Positional: Clone,
|
||||
{
|
||||
let min = chunk.min_y();
|
||||
let max = min + chunk.height() - 1;
|
||||
if inputs.write_min_y < min
|
||||
@@ -240,9 +243,13 @@ fn build_columns<R: RandomSource, S: MaterialSurfaceQueries, B: TerrainBlockStat
|
||||
extensions: &MaterialExtensions<R::Positional>,
|
||||
write_min_y: i32,
|
||||
write_max_y: i32,
|
||||
) -> Result<(), SurfaceError> {
|
||||
) -> Result<(), SurfaceError>
|
||||
where
|
||||
R::Positional: Clone,
|
||||
{
|
||||
let min = chunk.min_y();
|
||||
let rule = worker.add_rule(rule)?;
|
||||
let variants = worker.surface_variants(rule)?;
|
||||
let cx = chunk.position().x.wrapping_shl(4);
|
||||
let cz = chunk.position().z.wrapping_shl(4);
|
||||
for lx in 0..16 {
|
||||
@@ -321,6 +328,8 @@ fn build_columns<R: RandomSource, S: MaterialSurfaceQueries, B: TerrainBlockStat
|
||||
above = above.wrapping_add(1);
|
||||
worker.update_y(above, y.wrapping_sub(bottom).wrapping_add(1), water, y);
|
||||
if y >= write_min_y && y <= write_max_y {
|
||||
let above = variants.surface && y >= worker.minimum_surface_level()?;
|
||||
let rule = variants.rule(y, above);
|
||||
if let Some(state) = worker.try_apply(rule, BlockPos::new(x, y, z))? {
|
||||
column.set_block(y, state)?;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,27 @@ The contract between Horizon and its mods: the WIT package `horizon:[email protected]`,
|
||||
| `hud` | [`hud`](#hud) | draw text on your screen |
|
||||
| `storage` | [`storage`](#storage) | save its own settings to disk |
|
||||
| `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds |
|
||||
| `ui` | [`ui`](#ui) | show its own screens and a settings page |
|
||||
|
||||
## World `horizon-mod`
|
||||
|
||||
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`
|
||||
- Exports (what the mod provides): `events`
|
||||
|
||||
## World `horizon-ui-mod`
|
||||
|
||||
A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets.
|
||||
|
||||
- Imports (what the game offers): `ui`, `log`, `game`, `chat`, `hud`, `storage`
|
||||
- Exports (what the mod provides): `ui-events`, `events`
|
||||
|
||||
## World `ui-host`
|
||||
|
||||
The game's side of `horizon-ui-mod` beyond `horizon-mod`.
|
||||
|
||||
- Imports (what the game offers): `ui`
|
||||
- Exports (what the mod provides): `ui-events`
|
||||
|
||||
## `log`
|
||||
|
||||
Access: always granted.
|
||||
@@ -117,6 +132,79 @@ Capability `storage`: a per-mod folder of small key/value files, quota'd (docs/m
|
||||
| `set` | `func(key: string, value: list<u8>) -> result<_, string>` | Errs on a malformed key or over quota (bytes for the whole mod). |
|
||||
| `delete` | `func(key: string)` | No error if `key` was already absent. |
|
||||
|
||||
## `ui`
|
||||
|
||||
Access: capability `ui`.
|
||||
|
||||
Capability `ui`: widgets the game draws with its own menus' widgets, for a screen the mod owns and a settings page in Options > Mods. Layout and drawing are the game's: a mod says what its widgets hold, never where they sit or how they look, and the game clips everything it is handed.
|
||||
|
||||
### record `text-widget`
|
||||
|
||||
A line of text.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget; `ui-events` calls echo it. |
|
||||
| `text` | `string` | Up to 256 characters. |
|
||||
| `color` | `u32` | 0xRRGGBB; 0 is the menus' white. |
|
||||
|
||||
### record `item-widget`
|
||||
|
||||
An item's icon and a name beside it, as recipe viewers show them.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget. |
|
||||
| `item` | `string` | `minecraft:diamond`, `<mod>:<path>`; an unknown id draws a "?". |
|
||||
| `name` | `string` | The name shown beside the icon (empty: the item's own). |
|
||||
|
||||
### record `list-widget`
|
||||
|
||||
Rows the player picks one of, as an options page lists choices: a pick is `ui-events::select`, `selected` is the row to mark now.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `id` | `u32` | The mod's own name for the widget; `ui-events::select` echoes it. |
|
||||
| `rows` | `list<string>` | The rows' texts, at most 512, each up to 256 characters. |
|
||||
| `selected` | `option<u32>` | The row to mark as the current choice, if any. |
|
||||
|
||||
### variant `widget`
|
||||
|
||||
A thing on a mod's screen or settings page.
|
||||
|
||||
| Case | Payload | |
|
||||
| --- | --- | --- |
|
||||
| `text` | `text-widget` | Text the player reads. |
|
||||
| `item` | `item-widget` | An item's icon and name. |
|
||||
| `list` | `list-widget` | Rows the player picks from. |
|
||||
|
||||
### record `screen`
|
||||
|
||||
A screen the mod owns: a title over widgets stacked down the middle, the Done button the game adds, and Esc to leave it.
|
||||
|
||||
| Field | Type | |
|
||||
| --- | --- | --- |
|
||||
| `title` | `string` | Its title, up to 256 characters. |
|
||||
| `widgets` | `list<widget>` | Its widgets, in order, at most 64. |
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `open-screen` | `func(screen: screen)` | Show `screen` over the game; a second call while it is open replaces it. Only the last call of one hook counts. |
|
||||
| `close-screen` | `func()` | Close the mod's screen when it is open (the mod hears no `closed` for this: it was its own doing). |
|
||||
| `set-settings` | `func(widgets: list<widget>)` | The mod's page in Options > Mods, reached from a settings button on its row: the widgets it shows, an empty list for no page. The player opening the page is `ui-events::settings-opened`: push fresh widgets then; later calls update the page while it shows. |
|
||||
|
||||
## `ui-events`
|
||||
|
||||
Access: exported by the mod: the game calls it.
|
||||
|
||||
What the game calls on a mod that shows widgets (capability `ui`): what the player did with them.
|
||||
|
||||
| Function | Signature | What it does |
|
||||
| --- | --- | --- |
|
||||
| `select` | `func(id: u32, row: u32)` | A row of one of the mod's `list` widgets was picked: the widget's `id` and the row, on the screen or the settings page that showed it. |
|
||||
| `closed` | `func()` | The player left the mod's screen (Esc or Done); not for a screen the mod closed itself or one that went away with the mod. |
|
||||
| `settings-opened` | `func()` | The player opened the mod's settings page in Options > Mods: call `ui::set-settings` now to fill it with fresh widgets. |
|
||||
|
||||
## `events`
|
||||
|
||||
Access: exported by the mod: the game calls it.
|
||||
|
||||
@@ -87,6 +87,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
|
||||
| Part | Level | Version |
|
||||
| --- | --- | --- |
|
||||
| Mod API: `log`, `game`, `chat`, `hud`, `storage`, `events` | preview | 0.1.0 |
|
||||
| Mod API: `ui`, `ui-events` (a mod's screens and settings page) | experimental | manifest `experimental = ["ui"]` |
|
||||
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
|
||||
| Server mods (events, world, server commands) | not started | manifest `api = "0.3"`, experimental first |
|
||||
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
|
||||
|
||||
+6
-3
@@ -12,7 +12,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
|
||||
| Part | Where | State |
|
||||
|---|---|---|
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage; tick, frame, chat and command hooks |
|
||||
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, ui; tick, frame, chat and command hooks |
|
||||
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
|
||||
| Example mod | `crates/horizon-mod-sdk/examples/coords-hud` | coordinates, facing and time HUD, `/where`, `/coords` |
|
||||
| Runtime | `crates/horizon-mods` | wasmtime 36 LTS, component model |
|
||||
@@ -86,6 +86,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) |
|
||||
| `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters |
|
||||
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
|
||||
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` |
|
||||
|
||||
| Hook (export) | When |
|
||||
|---|---|
|
||||
@@ -112,7 +113,6 @@ loading. Ordered by what the first native ports need:
|
||||
| `target` | the crosshair's block or entity, with its state and NBT summary | Jade/WTHIT-style tooltips |
|
||||
| `inventory-read` | the player's inventory, held item, open container | AppleSkin, recipe viewers |
|
||||
| `recipes` | the recipe registry and lookups | JEI/EMI-style viewers |
|
||||
| `ui` | widgets: text, item icons, lists, a screen the mod owns, a settings page in Options > Mods | built from vanilla's widgets (`ui`) so it matches the menus |
|
||||
| `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics |
|
||||
| `render` | world-space lines, boxes and ghost blocks in a per-frame budget | Litematica-style placement, waypoints |
|
||||
| `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) |
|
||||
@@ -285,10 +285,13 @@ authors = ["..."]
|
||||
api = "0.1" # the API the SDK targets
|
||||
side = "client" # client | server | both
|
||||
capabilities = ["game-read", "hud", "chat"]
|
||||
experimental = [] # experimental capabilities it uses, e.g. ["ui"]
|
||||
```
|
||||
|
||||
Unknown keys are refused, so a typo is an error, not a silently ignored
|
||||
permission.
|
||||
permission. An experimental capability (today `ui`) may change in any release
|
||||
([stability](api/stability.md)): a mod uses it only by naming it in
|
||||
`experimental` as well, so nobody depends on it by accident.
|
||||
|
||||
### Content mods (API 0.2)
|
||||
|
||||
|
||||
+88
-9
@@ -1103,6 +1103,34 @@ fn with_java(cmd: &mut Command, home: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Minecraft folder a preparation whose game files are imported already reads: the sounds,
|
||||
/// the title panorama, the fonts and the languages come from its asset store. It is the one
|
||||
/// `resolve` found, linked into the official layout for another launcher's files; without one, the
|
||||
/// chosen folder, and the preparation goes without them, saying so. Before, such a preparation
|
||||
/// read the chosen folder as it was, which a Prism Launcher or Modrinth App install does not
|
||||
/// keep its assets in: the game was silent and its title screen grey.
|
||||
fn assets_folder(
|
||||
resolved: Result<sources::Resolved, String>,
|
||||
chosen: &Path,
|
||||
version: &str,
|
||||
log: &dyn Fn(String),
|
||||
) -> PathBuf {
|
||||
match resolved {
|
||||
Ok(sources::Resolved::Folder(dir)) => dir,
|
||||
Ok(sources::Resolved::NotFound(_)) => {
|
||||
log(format!(
|
||||
"Minecraft {version}'s assets were not found in any launcher folder: the game \
|
||||
will be silent and the title screen plain"
|
||||
));
|
||||
chosen.to_path_buf()
|
||||
}
|
||||
Err(e) => {
|
||||
log(format!("Minecraft {version}'s assets: {e}"));
|
||||
chosen.to_path_buf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks what the preparation needs and picks how to run it. PLAY.txt describes the same
|
||||
/// steps: the player's own launcher install, the server jar from that version's own json, and
|
||||
/// Java (and Python, for versions before horizon-prepare). Also returns the Minecraft folder
|
||||
@@ -1117,9 +1145,8 @@ pub fn preparer(
|
||||
let dir = version_dir(tag);
|
||||
let reused = horizon_prepare::artifacts::reuse_imported(&dir, &|line| log(line.to_string()))?;
|
||||
let chosen = minecraft.to_path_buf();
|
||||
let minecraft = if reused {
|
||||
minecraft.to_path_buf()
|
||||
} else {
|
||||
// Resolved even when the game files are reused: the assets still come from the install.
|
||||
let minecraft = {
|
||||
let pins = read_json(&dir.join("research-lock.json"))
|
||||
.and_then(|lock| sources::Pins::read(&lock))
|
||||
.ok_or("The version's research-lock.json doesn't pin the Minecraft files")?;
|
||||
@@ -1139,12 +1166,17 @@ pub fn preparer(
|
||||
Ok(body)
|
||||
};
|
||||
let linked = data_dir().join("linked-minecraft");
|
||||
match sources::resolve(minecraft, &pins, &known, &linked, &fetch, log)? {
|
||||
sources::Resolved::Folder(dir) => dir,
|
||||
sources::Resolved::NotFound(checked) => {
|
||||
let not_found = sources::not_found(&version, &checked, minecraft);
|
||||
download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?;
|
||||
own
|
||||
let resolved = sources::resolve(minecraft, &pins, &known, &linked, &fetch, log);
|
||||
if reused {
|
||||
assets_folder(resolved, &chosen, &version, log)
|
||||
} else {
|
||||
match resolved? {
|
||||
sources::Resolved::Folder(dir) => dir,
|
||||
sources::Resolved::NotFound(checked) => {
|
||||
let not_found = sources::not_found(&version, &checked, minecraft);
|
||||
download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?;
|
||||
own
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2353,6 +2385,53 @@ mod tests {
|
||||
use super::*;
|
||||
use std::cmp::Ordering::*;
|
||||
|
||||
/// A preparation that reuses the imported game files still reads the assets from the
|
||||
/// launcher that has them: a Prism Launcher instance's are in the launcher root, linked into
|
||||
/// the official layout (jan.sonki on Discord: silent, grey title screen since 0.2.48).
|
||||
#[test]
|
||||
fn reused_imports_take_the_assets_from_the_launcher_that_has_them() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-reused-assets-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let client = b"fake client jar";
|
||||
let manifest = br#"{"id": "26.3"}"#;
|
||||
let write = |path: &str, data: &[u8]| {
|
||||
let path = dir.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, data).unwrap();
|
||||
};
|
||||
let prism = "PrismLauncher";
|
||||
write(
|
||||
&format!("{prism}/libraries/com/mojang/minecraft/26.3/minecraft-26.3-client.jar"),
|
||||
client,
|
||||
);
|
||||
write(&format!("{prism}/assets/indexes/34.json"), b"{}");
|
||||
let instance = dir.join(prism).join("instances/26.3/minecraft");
|
||||
fs::create_dir_all(&instance).unwrap();
|
||||
let pins = sources::Pins::read(&serde_json::json!({ "minecraft": {
|
||||
"version": "26.3",
|
||||
"client_sha1": sources::sha1_hex(client),
|
||||
"manifest_sha1": sources::sha1_hex(manifest),
|
||||
}}))
|
||||
.unwrap();
|
||||
let linked = dir.join("linked");
|
||||
let fetch = |_: &str| Ok(manifest.to_vec());
|
||||
let lines = std::cell::RefCell::new(Vec::new());
|
||||
let log = |line: String| lines.borrow_mut().push(line);
|
||||
let resolved = sources::resolve(&instance, &pins, &[], &linked, &fetch, &log);
|
||||
let folder = assets_folder(resolved, &instance, "26.3", &log);
|
||||
assert_eq!(folder, linked);
|
||||
assert!(folder.join("assets/indexes/34.json").is_file());
|
||||
|
||||
// Nothing found: the chosen folder, and a line saying what the game will lack.
|
||||
let empty = dir.join("empty");
|
||||
fs::create_dir_all(&empty).unwrap();
|
||||
let resolved = sources::resolve(&empty, &pins, &[], &linked, &fetch, &log);
|
||||
assert_eq!(assets_folder(resolved, &empty, "26.3", &log), empty);
|
||||
assert!(lines.borrow().last().unwrap().contains("will be silent"));
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources_without_sounds_are_reported() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-no-sounds-{}", std::process::id()));
|
||||
|
||||
@@ -191,6 +191,24 @@ fn client_jars(base: &Path, version: &str) -> Vec<String> {
|
||||
jars.push(jar);
|
||||
}
|
||||
}
|
||||
jars.extend(loader_jars(base, version));
|
||||
jars
|
||||
}
|
||||
|
||||
/// The client jars the Modrinth App keeps for a mod loader's profile: `versions/<v>-<loader>/`,
|
||||
/// the vanilla jar under the loader's name, when only a Fabric or Quilt profile of the version
|
||||
/// was ever played (#1783). The jar is checked against the pin like the others.
|
||||
fn loader_jars(base: &Path, version: &str) -> Vec<String> {
|
||||
let prefix = format!("{version}-");
|
||||
let mut jars: Vec<String> = fs::read_dir(base.join("versions"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|entry| entry.file_name().into_string().ok())
|
||||
.filter(|name| name.starts_with(&prefix))
|
||||
.map(|name| format!("versions/{name}/{name}.jar"))
|
||||
.collect();
|
||||
jars.sort();
|
||||
jars
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,29 @@ fn the_modrinth_app_atlauncher_and_gdlauncher_are_found() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Modrinth App that only ever played a Fabric profile of 26.3 keeps the vanilla client jar
|
||||
/// under the loader's name (#1783); a profile of another version beside it is not taken.
|
||||
#[test]
|
||||
fn a_modrinth_loader_profile_has_the_client_jar() {
|
||||
let s = Scratch::new("modrinth-fabric");
|
||||
s.write(
|
||||
"ModrinthApp/meta/versions/26.3.1-0.18.1/26.3.1-0.18.1.jar",
|
||||
b"other",
|
||||
);
|
||||
let jar = "versions/26.3-0.18.1/26.3-0.18.1.jar";
|
||||
let dir = s.launcher("ModrinthApp", "meta", jar);
|
||||
s.write(
|
||||
"ModrinthApp/meta/versions/26.3-0.18.1/26.3-0.18.1.json",
|
||||
br#"{"id":"26.3-0.18.1","inheritsFrom":"26.3"}"#,
|
||||
);
|
||||
let profile = dir.join("profiles/Fabric 26.3");
|
||||
fs::create_dir_all(&profile).unwrap();
|
||||
let linked = s.0.join("linked");
|
||||
let got = resolve_with(&profile, &[], &linked, &Mojang::new(MANIFEST)).unwrap();
|
||||
assert_eq!(got, linked);
|
||||
assert_linked(&linked, &dir.join("meta"), jar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_folder_may_be_chosen() {
|
||||
let s = Scratch::new("instance");
|
||||
|
||||
@@ -84,7 +84,12 @@ import net.minecraft.world.phys.Vec3;
|
||||
* still), {@code sync NAME} (runs {@code time query gametime}, whose reply is
|
||||
* not recorded, and prints {@code synced NAME} once it is back: the server has
|
||||
* handled everything this player sent before, and everything the server sent
|
||||
* this player before the reply has been recorded), {@code quit NAME}, {@code
|
||||
* this player before the reply has been recorded), {@code respawn NAME} (the
|
||||
* death screen's button, ServerboundClientCommandPacket PERFORM_RESPAWN),
|
||||
* {@code use NAME PITCH} (the main hand's item used looking that way,
|
||||
* ServerboundUseItemPacket), {@code pickup NAME SLOT} (a left click on that
|
||||
* slot of the player's inventory menu, claiming nothing), {@code quit NAME},
|
||||
* {@code
|
||||
* players on|off} (whether each player also records the other players added
|
||||
* to and removed from its level, and its own respawns into a dimension),
|
||||
* {@code chat on|off} (whether the system chat each player receives is
|
||||
@@ -130,6 +135,12 @@ public final class LocatorBarClient {
|
||||
players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities));
|
||||
}
|
||||
case "still" -> players.get(w[1]).still();
|
||||
case "respawn" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket(
|
||||
net.minecraft.network.protocol.game.ServerboundClientCommandPacket.Action.PERFORM_RESPAWN));
|
||||
case "use" -> players.get(w[1]).use(Float.parseFloat(w[2]));
|
||||
case "pickup" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
0, 0, Short.parseShort(w[2]), (byte) 0, net.minecraft.world.inventory.ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
|
||||
case "sync" -> {
|
||||
try {
|
||||
players.get(w[1]).sync();
|
||||
@@ -250,6 +261,13 @@ public final class LocatorBarClient {
|
||||
}
|
||||
}
|
||||
|
||||
private int sequence;
|
||||
|
||||
void use(float pitch) throws Exception {
|
||||
send(new net.minecraft.network.protocol.game.ServerboundUseItemPacket(
|
||||
net.minecraft.world.InteractionHand.MAIN_HAND, ++sequence, position.yRot(), pitch));
|
||||
}
|
||||
|
||||
void still() throws Exception {
|
||||
PositionMoveRotation at = position;
|
||||
// A tick of its own: the server takes one position per client tick (receivedPositionThisTick).
|
||||
|
||||
+723
-16
@@ -49,10 +49,22 @@ name = "Rendering"
|
||||
id = "launcher"
|
||||
name = "Launcher"
|
||||
|
||||
[[area]]
|
||||
id = "platforms"
|
||||
name = "Platforms"
|
||||
|
||||
[[area]]
|
||||
id = "extras"
|
||||
name = "Horizon extras"
|
||||
|
||||
[[area]]
|
||||
id = "ai"
|
||||
name = "AI and agents"
|
||||
|
||||
[[area]]
|
||||
id = "community"
|
||||
name = "Community"
|
||||
|
||||
[[area]]
|
||||
id = "project"
|
||||
name = "Code and tools"
|
||||
@@ -206,6 +218,75 @@ dev = """A review of the whole tree: dead code and stubs removed (#1432, #1658),
|
||||
feature to its files and harnesses."""
|
||||
links = ["#1432", "#1658", "#1453", "#1454", "#1448", "#1707", "docs/architecture.md"]
|
||||
|
||||
[[item]]
|
||||
id = "native-shaders"
|
||||
title = "Horizon's own shader format and kit"
|
||||
status = "now"
|
||||
area = "rendering"
|
||||
plain = """Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it \
|
||||
runs, and the registry will list them."""
|
||||
dev = """`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, \
|
||||
options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the \
|
||||
example pack Golden Hour. Merged: the check every pack passes (#1820) and the tool, templates and guide \
|
||||
(#1822). In review: the post stage running in the game with hot reload (#1861), the sky stage (#1863) and \
|
||||
registry checks that use the game's own check (#1855). Left: the material, lighting and shadow stages, a \
|
||||
last-good fallback on reload, a GPU-time overlay per stage and budgets (docs/shaders.md, \
|
||||
docs/api/shaders.md). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from \
|
||||
scratch'."""
|
||||
links = ["#1820", "#1822", "#1861", "#1863", "#1855", "docs/shaders.md", "docs/api/shaders.md"]
|
||||
|
||||
[[item]]
|
||||
id = "server-mods"
|
||||
title = "Mods that run on the server"
|
||||
status = "now"
|
||||
area = "mods"
|
||||
plain = """Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react \
|
||||
to players joining, chat and blocks being broken or placed, and that add commands."""
|
||||
dev = """API 0.3, experimental first (#1833, in review): the same sandboxed component runs on the server thread and \
|
||||
the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or \
|
||||
left), game rules and server commands with typed arguments. The world, item and entity interfaces come after \
|
||||
(see 'Mods that read and change the world'). docs/api/mods.md, docs/mods.md."""
|
||||
links = ["#1833", "docs/api/mods.md", "docs/mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "mod-screens"
|
||||
title = "Screens, input and drawing for mods"
|
||||
status = "now"
|
||||
area = "mods"
|
||||
plain = """Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger \
|
||||
mods, such as the Orbital Laser, can run on the normal game."""
|
||||
dev = """Capability `ui` is in review (#1864): mod-owned screens and a settings page under Options > Mods, built from \
|
||||
vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a \
|
||||
budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance \
|
||||
test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game')."""
|
||||
links = ["#1864", "docs/mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "modder-tools"
|
||||
title = "Tools for people who make mods"
|
||||
status = "now"
|
||||
area = "mods"
|
||||
plain = """Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and \
|
||||
clear promises about what keeps working from one version to the next."""
|
||||
dev = """`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot (#1816) and, for server \
|
||||
mods, scripted server events (#1837); the content template and the guides are merged (#1859, #1810, \
|
||||
docs/api/stability.md). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a \
|
||||
frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference \
|
||||
entry is removed without a new major version, and the SDK on crates.io once the API is stable."""
|
||||
links = ["#1816", "#1837", "docs/api/stability.md", "website/docs/test-a-mod.md"]
|
||||
|
||||
[[item]]
|
||||
id = "pregen"
|
||||
title = "Pre-generate an area with /pregen"
|
||||
status = "now"
|
||||
area = "servers"
|
||||
plain = """A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when \
|
||||
they explore."""
|
||||
dev = """Generates, lights and saves an area before anyone walks there (#1805, a contributor's pull request, in \
|
||||
review). Useful on servers, and it moves the generation cost away from the first visit; the generation \
|
||||
speed-ups are under 'Steady frames at long render distances'."""
|
||||
links = ["#1805"]
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------- Next
|
||||
|
||||
[[item]]
|
||||
@@ -239,11 +320,14 @@ id = "benchmarks"
|
||||
title = "Fresh benchmarks against vanilla"
|
||||
status = "next"
|
||||
area = "performance"
|
||||
plain = """New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way \
|
||||
every time, on the website's Benchmarks page."""
|
||||
plain = """New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every \
|
||||
time, with a history that shows what each release changed."""
|
||||
dev = """Redo the comparison after the current performance work (#1073); reliability, coverage and presentation \
|
||||
(#1346); a scene must count as loaded only once its first chunk arrives (#1749). Players' reports come in through \
|
||||
`--benchmark` and `horizon-launcher data benchmark` (docs/benchmark.md)."""
|
||||
(#1346); a scene must count as loaded only once its first chunk arrives (#1749). Next: a performance history \
|
||||
across releases (an automatic comparison of each release with the last, on a timeline), more report fields \
|
||||
(server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on \
|
||||
battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and \
|
||||
`horizon-launcher data benchmark` (docs/benchmark.md)."""
|
||||
links = ["#1073", "#1346", "#1749", "docs/benchmark.md"]
|
||||
|
||||
[[item]]
|
||||
@@ -262,36 +346,659 @@ links = ["#1496", "#1609", "#1608", "#1704", "#1758"]
|
||||
id = "intel-mac"
|
||||
title = "Horizon on Intel Macs"
|
||||
status = "next"
|
||||
area = "launcher"
|
||||
plain = """Builds of the game and the launcher for Macs with an Intel processor."""
|
||||
area = "platforms"
|
||||
plain = "Builds of the game and the launcher for Macs with an Intel processor."
|
||||
dev = """x86_64 game and launcher builds, picked per processor (#1171). Before it ships it needs a run on a real \
|
||||
Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: #1161, #1792."""
|
||||
links = ["#1171", "#1161", "#1792"]
|
||||
|
||||
[[item]]
|
||||
id = "world-entry"
|
||||
title = "Faster world creation and entry on small CPUs"
|
||||
status = "next"
|
||||
area = "performance"
|
||||
plain = """Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 \
|
||||
chunks can make you wait minutes for the first terrain."""
|
||||
dev = """Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on \
|
||||
structure starts, and leave one core free for the rest of the game as vanilla does (#1760 and #1778, in \
|
||||
review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the \
|
||||
first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. \
|
||||
Measured with `tools/perf_bench.py` (docs/benchmark.md, docs/profiling.md)."""
|
||||
links = ["docs/benchmark.md", "docs/profiling.md"]
|
||||
|
||||
[[item]]
|
||||
id = "server-tick"
|
||||
title = "Servers that hold 20 TPS with 50 to 100 players"
|
||||
status = "next"
|
||||
area = "performance"
|
||||
plain = """Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops \
|
||||
to about 10 at 100."""
|
||||
dev = """Measured against vanilla on equal work (docs/server.md, Scale): Horizon is ahead with few players and when \
|
||||
exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity \
|
||||
changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks \
|
||||
and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark \
|
||||
(`horizon-bots`) is the proof."""
|
||||
links = ["docs/server.md"]
|
||||
|
||||
[[item]]
|
||||
id = "low-end-mode"
|
||||
title = "Low-end computers: Steam Deck, old Macs, 8 GB"
|
||||
status = "next"
|
||||
area = "performance"
|
||||
plain = """Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of \
|
||||
memory, with a battery mode that saves power on laptops."""
|
||||
dev = """Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and \
|
||||
animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory \
|
||||
use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported \
|
||||
on the Benchmarks page."""
|
||||
links = ["docs/benchmark.md", "website/docs/performance.md"]
|
||||
|
||||
[[item]]
|
||||
id = "upscaling"
|
||||
title = "Native upscaling"
|
||||
status = "next"
|
||||
area = "performance"
|
||||
plain = """Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain \
|
||||
frames on weaker graphics cards. An optional graphics setting."""
|
||||
dev = """A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. \
|
||||
Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue \
|
||||
#1403."""
|
||||
links = ["#1403"]
|
||||
|
||||
[[item]]
|
||||
id = "targeted-optimisations"
|
||||
title = "A batch of targeted optimisations"
|
||||
status = "next"
|
||||
area = "performance"
|
||||
plain = """Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster \
|
||||
structure placement, high-rate mice and very large texture packs."""
|
||||
dev = """Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is \
|
||||
near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw \
|
||||
shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial \
|
||||
chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas \
|
||||
packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy \
|
||||
scenes (#1461). Profile first, keep what pays, show before and after numbers."""
|
||||
links = ["#1461", "docs/profiling.md"]
|
||||
|
||||
[[item]]
|
||||
id = "convert-to-rust"
|
||||
title = "A Convert to Rust button for what isn't rewritten yet"
|
||||
status = "next"
|
||||
area = "modpacks"
|
||||
plain = """When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be \
|
||||
converted, so you don't have to wait for the rewrite."""
|
||||
dev = """Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item \
|
||||
definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API \
|
||||
allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. \
|
||||
The browser says what works before the install. Java code itself does not run (docs/modpacks.md, \
|
||||
docs/mod-browser.md)."""
|
||||
links = ["docs/modpacks.md", "docs/mod-browser.md"]
|
||||
|
||||
[[item]]
|
||||
id = "code-mods"
|
||||
title = "Mods with code: machines, magic, mobs"
|
||||
status = "next"
|
||||
area = "modpacks"
|
||||
plain = """Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are \
|
||||
most of what a big modpack is made of."""
|
||||
dev = """333 of the 496 mods in a big pack (ATM10) contain code. Issue #1854 ranks the API gaps (ticking block \
|
||||
entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the \
|
||||
recommended path and a furnace ported tick for tick as proof. The first API step is a draft (#1189, see \
|
||||
'Machines and blocks with inventories'). The path isn't started yet (docs/modpacks.md)."""
|
||||
links = ["#1854", "docs/modpacks.md"]
|
||||
|
||||
[[item]]
|
||||
id = "datapack-browser"
|
||||
title = "Data packs in the mod browser"
|
||||
status = "next"
|
||||
area = "modpacks"
|
||||
plain = """Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and \
|
||||
resource packs."""
|
||||
dev = """A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the \
|
||||
same checks and 'works in Horizon' notes as the other kinds (docs/mod-browser.md)."""
|
||||
links = ["docs/mod-browser.md"]
|
||||
|
||||
[[item]]
|
||||
id = "prepare-without-java"
|
||||
title = "Prepare a version without Java"
|
||||
status = "next"
|
||||
area = "servers"
|
||||
plain = """Set up Horizon, a server first, without Java on your computer: published tables are combined with your own \
|
||||
copy of the game."""
|
||||
dev = """Today `horizon-prepare` runs Mojang's exporters with Java. Issue #1750 proposes publishing the computed \
|
||||
version tables once from CI and assembling the rest locally from the player's own jar and assets, provided \
|
||||
the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A \
|
||||
server-only prepare from `server.jar` alone is the first step (docs/server.md)."""
|
||||
links = ["#1750", "docs/server.md"]
|
||||
|
||||
[[item]]
|
||||
id = "server-admin"
|
||||
title = "Server console, bans and a full admin API"
|
||||
status = "next"
|
||||
area = "servers"
|
||||
plain = """Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin \
|
||||
API and an MCP server for AI agents."""
|
||||
dev = """`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API \
|
||||
additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics \
|
||||
endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, \
|
||||
server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC \
|
||||
over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon \
|
||||
server. docs/server.md, docs/api/server.md."""
|
||||
links = ["docs/server.md", "docs/api/server.md"]
|
||||
|
||||
[[item]]
|
||||
id = "hosting-polish"
|
||||
title = "Hosting polish: Windows service, safe updates"
|
||||
status = "next"
|
||||
area = "servers"
|
||||
plain = """Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each \
|
||||
player's state, and a changed view distance or message without a restart."""
|
||||
dev = """A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config \
|
||||
for view distance and the message of the day (needs engine support); backups that keep world and player \
|
||||
state (#1808, a contributor's draft, in review). docs/hosting.md, docs/server.md."""
|
||||
links = ["#1808", "docs/hosting.md"]
|
||||
|
||||
[[item]]
|
||||
id = "tailscale-hosting"
|
||||
title = "Guided hosting with Tailscale and an invite button"
|
||||
status = "next"
|
||||
area = "servers"
|
||||
plain = """In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to \
|
||||
open ports on a router."""
|
||||
dev = """On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that \
|
||||
explains and checks each step, and an invite button that hands friends what they need to join over the \
|
||||
tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over \
|
||||
Tailscale is already described in docs/server.md."""
|
||||
links = ["docs/hosting.md", "docs/server.md"]
|
||||
|
||||
[[item]]
|
||||
id = "docker-image"
|
||||
title = "An official Docker image for servers"
|
||||
status = "next"
|
||||
area = "servers"
|
||||
plain = """An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game \
|
||||
files mounted in."""
|
||||
dev = """The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian \
|
||||
bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and \
|
||||
friends answering the first-start questions), and anyone can build it (docs/hosting.md, section 9). Left: \
|
||||
publish the image to a public registry from the release workflow, tagged per release, so it follows the same \
|
||||
checks, and update the docs and the launcher. The image never holds Mojang's files."""
|
||||
links = ["docs/hosting.md", "docs/server.md"]
|
||||
|
||||
[[item]]
|
||||
id = "one-single-player-path"
|
||||
title = "One single-player path: remove the in-frame world"
|
||||
status = "next"
|
||||
area = "gameplay"
|
||||
plain = """Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer \
|
||||
bugs. The older in-frame mode goes once everything passes without it."""
|
||||
dev = """`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the \
|
||||
window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity \
|
||||
harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' \
|
||||
and docs/server-levels.md."""
|
||||
links = ["docs/server-levels.md"]
|
||||
|
||||
[[item]]
|
||||
id = "villages-combat-parity"
|
||||
title = "Villages and combat, exactly as in vanilla"
|
||||
status = "next"
|
||||
area = "gameplay"
|
||||
plain = """Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their \
|
||||
homes, mace smashes and knockback."""
|
||||
dev = """Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on \
|
||||
dedicated servers, flower offers (#1639 is one report). Combat: the mobs' `attack_knockback` attribute, mace \
|
||||
smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against \
|
||||
vanilla by a harness (docs/rust-parity.md)."""
|
||||
links = ["#1639", "docs/rust-parity.md"]
|
||||
|
||||
[[item]]
|
||||
id = "audio-device"
|
||||
title = "Choose the audio output device"
|
||||
status = "next"
|
||||
area = "gameplay"
|
||||
plain = "Pick the speakers or headphones Horizon plays through, in Music & Sounds."
|
||||
dev = """An output device list in Music & Sounds, falling back to the system default. #1035 started it: a player on \
|
||||
an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed."""
|
||||
links = ["#1035"]
|
||||
|
||||
[[item]]
|
||||
id = "mod-world-items"
|
||||
title = "Mods that read and change the world"
|
||||
status = "next"
|
||||
area = "mods"
|
||||
plain = """Mods that can see and change blocks, read inventories and items, and find and affect entities, all through \
|
||||
the server so every player sees the same thing."""
|
||||
dev = """After server mods: read blocks, states and biomes in loaded chunks and change them through the server's \
|
||||
block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, \
|
||||
open container and item stacks with components; entity queries near a position, a safe subset of entity data \
|
||||
and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot \
|
||||
paths. docs/api/mods.md ('Not there yet')."""
|
||||
links = ["docs/api/mods.md", "docs/modpacks.md"]
|
||||
|
||||
[[item]]
|
||||
id = "machines-api"
|
||||
title = "Machines and blocks with inventories (API 0.4)"
|
||||
status = "next"
|
||||
area = "mods"
|
||||
plain = """Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology \
|
||||
mods of big modpacks."""
|
||||
dev = """Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, \
|
||||
energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed \
|
||||
client and server channel and typed config beside them. A draft (#1189) proves a furnace ported tick for \
|
||||
tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges \
|
||||
(docs/modpacks.md, docs/api/stability.md)."""
|
||||
links = ["#1189", "docs/modpacks.md"]
|
||||
|
||||
[[item]]
|
||||
id = "mod-languages"
|
||||
title = "Lua and other languages for mods"
|
||||
status = "next"
|
||||
area = "mods"
|
||||
plain = """Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass \
|
||||
the tests. Every language runs in the same sandbox."""
|
||||
dev = """Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest \
|
||||
names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest \
|
||||
can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or \
|
||||
TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a \
|
||||
template, an example and a CI test. docs/api/mods.md ('Ways in')."""
|
||||
links = ["docs/api/mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "shader-pack-examples"
|
||||
title = "Well-known shader looks, rewritten for Horizon"
|
||||
status = "next"
|
||||
area = "rendering"
|
||||
plain = """Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose \
|
||||
licence is open source and compatible with ours are rewritten, never anything else."""
|
||||
dev = """The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and \
|
||||
recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the \
|
||||
rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. \
|
||||
Today only the Golden Hour example exists (`crates/horizon-shader-api/packs/golden_hour`)."""
|
||||
links = ["docs/shaders.md", "crates/horizon-shader-api/packs/golden_hour"]
|
||||
|
||||
[[item]]
|
||||
id = "beta-label"
|
||||
title = "A visible beta label"
|
||||
status = "next"
|
||||
area = "launcher"
|
||||
plain = """A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built."""
|
||||
dev = """Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the \
|
||||
version number (docs/versioning.md)."""
|
||||
links = ["docs/versioning.md"]
|
||||
|
||||
[[item]]
|
||||
id = "wayland-polish"
|
||||
title = "Wayland and Hyprland polish"
|
||||
status = "next"
|
||||
area = "platforms"
|
||||
plain = """Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a \
|
||||
restart and the like."""
|
||||
dev = """Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it (#1866). Earlier \
|
||||
reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay (#1007, closed). \
|
||||
Checked on a real Wayland session."""
|
||||
links = ["#1866"]
|
||||
|
||||
[[item]]
|
||||
id = "agent-control-protocol"
|
||||
title = "A versioned agent-control protocol"
|
||||
status = "next"
|
||||
area = "ai"
|
||||
plain = """A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an \
|
||||
event stream so tools keep working as Horizon changes."""
|
||||
dev = """`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; \
|
||||
reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; \
|
||||
`subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. \
|
||||
Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: docs/agent-control.md, \
|
||||
docs/api/agent-control.md."""
|
||||
links = ["docs/agent-control.md", "docs/api/agent-control.md", "docs/api/stability.md"]
|
||||
|
||||
[[item]]
|
||||
id = "own-agents-porting"
|
||||
title = "Port mods with your own AI agent"
|
||||
status = "next"
|
||||
area = "ai"
|
||||
plain = """You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives \
|
||||
you the tools, but provides no hosted agents."""
|
||||
dev = """A player runs their own agent locally with `horizon-ctl` and the MCP servers (docs/agent-control.md), `cargo \
|
||||
horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide \
|
||||
(website/docs/agents.md, docs/modpacks.md) and runs no agents, accounts or keys for players. The No AI \
|
||||
switch turns all of it off."""
|
||||
links = ["website/docs/agents.md", "docs/modpacks.md", "docs/agent-control.md"]
|
||||
|
||||
[[item]]
|
||||
id = "x-account"
|
||||
title = "Horizon on X (coming soon)"
|
||||
status = "next"
|
||||
area = "community"
|
||||
plain = "Coming soon: an official Horizon account on X, used to announce releases and the weekly summary."
|
||||
dev = """Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as \
|
||||
soon as the account's credentials exist (roadmap/README.md, 'The journal'). Waiting for the account itself."""
|
||||
links = ["roadmap/README.md"]
|
||||
|
||||
[[item]]
|
||||
id = "agents-app"
|
||||
title = "Pipeline protection and a GitHub App for agents"
|
||||
status = "next"
|
||||
area = "project"
|
||||
plain = """Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller \
|
||||
identity of their own instead of a personal account."""
|
||||
dev = """`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the \
|
||||
installers (#1015); the branch protection that makes it count waits for the `horizon-agent` GitHub App, \
|
||||
which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or \
|
||||
members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on \
|
||||
(docs/security.md)."""
|
||||
links = ["docs/security.md"]
|
||||
|
||||
[[item]]
|
||||
id = "signed-installers"
|
||||
title = "Signed updates everywhere, installers included"
|
||||
status = "next"
|
||||
area = "project"
|
||||
plain = """Every download Horizon makes is checked against the project's signature, including the first install with \
|
||||
install.sh and install.ps1."""
|
||||
dev = """Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned \
|
||||
(#1011, #1014). Left: the first install through `install.sh` and `install.ps1` still trusts only the \
|
||||
checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship \
|
||||
(docs/security.md)."""
|
||||
links = ["docs/security.md"]
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------- Later
|
||||
|
||||
[[item]]
|
||||
id = "ai-companion"
|
||||
title = "An optional AI teammate"
|
||||
status = "later"
|
||||
area = "extras"
|
||||
plain = """An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the \
|
||||
No AI switch hides it completely."""
|
||||
dev = """Draft pull requests: the companion (#1068), its safety modes and their evaluation (#1069), and a route \
|
||||
that finishes the game (#1070). Local models first; it ships only once the safety evaluation passes."""
|
||||
area = "ai"
|
||||
plain = """An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, \
|
||||
runs on your computer first, and exists only if you turn it on. The No AI switch hides it."""
|
||||
dev = """Draft pull requests: the companion (#1068), its safety modes and their evaluation (#1069) and a route that \
|
||||
finishes the game (#1070). In the drafts: a companion that follows, fights, mines, crafts, sleeps and \
|
||||
respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, \
|
||||
helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to \
|
||||
All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys \
|
||||
optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably \
|
||||
yet, voices for more languages, and the safety evaluation, which must pass before it ships."""
|
||||
links = ["#1068", "#1069", "#1070"]
|
||||
|
||||
[[item]]
|
||||
id = "new-versions"
|
||||
title = "Following new Minecraft versions"
|
||||
title = "Every Minecraft version, prepared automatically"
|
||||
status = "later"
|
||||
area = "gameplay"
|
||||
plain = """When Mojang releases a new version of the game, Horizon follows it, and every system is checked against \
|
||||
the new version again."""
|
||||
dev = """The port pipeline detects a release, diffs its classes against 26.3 and writes a port report \
|
||||
(docs/version-ports.md); the harnesses then run against the new version."""
|
||||
plain = """Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, \
|
||||
Horizon follows it, and every system is checked against it again."""
|
||||
dev = """The port pipeline (docs/version-ports.md, `tools/version_port.py`) detects a release, prepares it, diffs its \
|
||||
classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and \
|
||||
writes a port report that becomes issues. The goal is as little human work as possible for any version, past \
|
||||
or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't \
|
||||
installed yet. Two versions in one build are out of scope."""
|
||||
links = ["docs/version-ports.md"]
|
||||
|
||||
[[item]]
|
||||
id = "exact-far-terrain"
|
||||
title = "Exact far terrain from real chunks"
|
||||
status = "later"
|
||||
area = "performance"
|
||||
plain = """Far terrain drawn from the world you really built and explored, caves and builds included, not only a \
|
||||
preview from the seed."""
|
||||
dev = """Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas \
|
||||
nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the \
|
||||
far-terrain levels of detail already shipped; the open work on them is #718."""
|
||||
links = ["#718"]
|
||||
|
||||
[[item]]
|
||||
id = "older-jars"
|
||||
title = "Older Fabric and Forge mods"
|
||||
status = "later"
|
||||
area = "modpacks"
|
||||
plain = """Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over \
|
||||
like newer ones."""
|
||||
dev = """A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids \
|
||||
with dashes (`fabric-api`); the port step then treats them like newer jars (docs/mod-browser.md)."""
|
||||
links = ["docs/mod-browser.md"]
|
||||
|
||||
[[item]]
|
||||
id = "modpack-own-packs"
|
||||
title = "A modpack's own resource and shader packs"
|
||||
status = "later"
|
||||
area = "modpacks"
|
||||
plain = """Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use \
|
||||
them."""
|
||||
dev = """Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it \
|
||||
are left out. Resource packs can be carried as they are; shader packs go through the converter \
|
||||
(docs/modpacks.md)."""
|
||||
links = ["docs/modpacks.md"]
|
||||
|
||||
[[item]]
|
||||
id = "whole-modpacks"
|
||||
title = "Whole modpacks, such as ATM10, rewritten"
|
||||
status = "later"
|
||||
area = "modpacks"
|
||||
plain = """Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: \
|
||||
the work above comes first."""
|
||||
dev = """The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It \
|
||||
was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of \
|
||||
its 496 mods are carried at least in part (docs/modpacks.md). It depends on 'Mods with code' and the native \
|
||||
mod API."""
|
||||
links = ["docs/modpacks.md"]
|
||||
|
||||
[[item]]
|
||||
id = "java-mod-bridge"
|
||||
title = "Running Java mods that aren't rewritten"
|
||||
status = "later"
|
||||
area = "modpacks"
|
||||
plain = """Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not \
|
||||
started: the Convert button and native rewrites come first."""
|
||||
dev = """Earlier research (#527, closed) found that Java mods can't simply run, because they call Minecraft's own \
|
||||
classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. \
|
||||
Whatever is chosen has to run inside the same sandbox and capability checks as native mods."""
|
||||
links = ["#527"]
|
||||
|
||||
[[item]]
|
||||
id = "alt-accounts"
|
||||
title = "Ely.by and other sign-in providers"
|
||||
status = "later"
|
||||
area = "servers"
|
||||
plain = """Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft."""
|
||||
dev = """Issue #1270 asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It \
|
||||
builds on the Microsoft sign-in (docs/microsoft-account.md)."""
|
||||
links = ["#1270", "docs/microsoft-account.md"]
|
||||
|
||||
[[item]]
|
||||
id = "friends-online-mode"
|
||||
title = "Online-mode checks for Play with Friends"
|
||||
status = "later"
|
||||
area = "servers"
|
||||
plain = """Friends who join through an invite code could prove who they are with their Microsoft account, as on \
|
||||
online-mode servers."""
|
||||
dev = """Play with Friends joins over an encrypted tunnel with an invite code (docs/play-with-friends.md, \
|
||||
'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; \
|
||||
they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between \
|
||||
players, with no relay run by the project."""
|
||||
links = ["docs/play-with-friends.md"]
|
||||
|
||||
[[item]]
|
||||
id = "orbital-laser"
|
||||
title = "Orbital Laser on the normal game"
|
||||
status = "later"
|
||||
area = "mods"
|
||||
plain = """The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the \
|
||||
registry as the showcase of the mod API."""
|
||||
dev = """A pull request on https://github.com/Stink-O/orbital-laser (MIT) once the input, ui, render and world \
|
||||
capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its \
|
||||
author's fork. Tested in the game, and listed in the registry."""
|
||||
links = ["https://github.com/Stink-O/orbital-laser"]
|
||||
|
||||
[[item]]
|
||||
id = "client-mod-natives"
|
||||
title = "Native versions of popular client mods"
|
||||
status = "later"
|
||||
area = "mods"
|
||||
plain = """Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world \
|
||||
map and schematic overlays."""
|
||||
dev = """Done as extras: a mod list and menu, a minimap and a zoom (docs/extras.md). Left: block info tooltips, a \
|
||||
recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a \
|
||||
schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` \
|
||||
in the mod API, or they go into the game directly."""
|
||||
links = ["docs/extras.md", "docs/mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "create-physics"
|
||||
title = "Create-style physics as a native mod"
|
||||
status = "later"
|
||||
area = "mods"
|
||||
plain = """Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the \
|
||||
base game."""
|
||||
dev = """Issue #805 (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. \
|
||||
It needs server mods, entities and rendering first."""
|
||||
links = ["#805"]
|
||||
|
||||
[[item]]
|
||||
id = "proximity-voice"
|
||||
title = "Proximity voice chat as a native mod"
|
||||
status = "later"
|
||||
area = "mods"
|
||||
plain = """Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod."""
|
||||
dev = """Needs server mods, mod networking and an audio interface in the mod API before anything can be built \
|
||||
(docs/api/mods.md)."""
|
||||
links = ["docs/api/mods.md"]
|
||||
|
||||
[[item]]
|
||||
id = "light-default-pack"
|
||||
title = "A light default shader pack"
|
||||
status = "later"
|
||||
area = "rendering"
|
||||
plain = """A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players \
|
||||
who want a nicer picture without a heavy pack."""
|
||||
dev = """An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader \
|
||||
Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it."""
|
||||
links = ["docs/shaders.md"]
|
||||
|
||||
[[item]]
|
||||
id = "ray-tracing"
|
||||
title = "Optional ray tracing"
|
||||
status = "later"
|
||||
area = "rendering"
|
||||
plain = """An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water \
|
||||
reflections and global lighting on top of the vanilla look."""
|
||||
dev = """A spike with ray-traced sun shadows, off by default, is a draft (#1180). Reflections, global illumination, \
|
||||
Apple M3 and newer if it is cheap, and a performance report per card come after."""
|
||||
links = ["#1180"]
|
||||
|
||||
[[item]]
|
||||
id = "hdr"
|
||||
title = "HDR monitors"
|
||||
status = "later"
|
||||
area = "rendering"
|
||||
plain = "Output for HDR monitors: brighter highlights and richer colour where your screen supports it."
|
||||
dev = """Issue #1441. Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on \
|
||||
Windows, macOS and Linux."""
|
||||
links = ["#1441"]
|
||||
|
||||
[[item]]
|
||||
id = "msaa-fullscreen"
|
||||
title = "MSAA, anisotropic filtering, exclusive fullscreen"
|
||||
status = "later"
|
||||
area = "rendering"
|
||||
plain = """More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. \
|
||||
Today Horizon has FXAA and borderless fullscreen."""
|
||||
dev = """Not implemented (docs/graphics.md). Anisotropic filtering needs linear magnification in wgpu, which blurs \
|
||||
close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen \
|
||||
where the backend supports it."""
|
||||
links = ["docs/graphics.md"]
|
||||
|
||||
[[item]]
|
||||
id = "video-studio"
|
||||
title = "A complete video studio inside the game"
|
||||
status = "later"
|
||||
area = "extras"
|
||||
plain = """Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and \
|
||||
finish the video. One of Horizon's original goals."""
|
||||
dev = """Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. \
|
||||
Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it \
|
||||
exists yet; the agent-control socket (docs/agent-control.md) already lets tools drive the game for scripted \
|
||||
shots."""
|
||||
links = ["docs/agent-control.md"]
|
||||
|
||||
[[item]]
|
||||
id = "controllers"
|
||||
title = "Controllers: tested on real pads, and in the launcher"
|
||||
status = "later"
|
||||
area = "gameplay"
|
||||
plain = """Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in \
|
||||
the launcher too."""
|
||||
dev = """The game side is merged (docs/controllers.md): pads read with gilrs, menus, rumble, glyphs, remapping and \
|
||||
DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck \
|
||||
(DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus \
|
||||
navigation in the GPUI launcher."""
|
||||
links = ["docs/controllers.md"]
|
||||
|
||||
[[item]]
|
||||
id = "debug-screen"
|
||||
title = "The full debug screen"
|
||||
status = "later"
|
||||
area = "gameplay"
|
||||
plain = """Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump."""
|
||||
dev = """Today F3 shows position, chunk, light, biome and FPS (docs/native-client.md). Vanilla also has F3+F6 debug \
|
||||
options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump (#562 asked \
|
||||
for the F3 editor)."""
|
||||
links = ["#562", "docs/native-client.md"]
|
||||
|
||||
[[item]]
|
||||
id = "lean-installs"
|
||||
title = "Lean installs that use less disk"
|
||||
status = "later"
|
||||
area = "launcher"
|
||||
plain = """Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the \
|
||||
preparation needed."""
|
||||
dev = """A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current \
|
||||
and the previous version kept, and an automatic clean-up. Not started."""
|
||||
|
||||
[[item]]
|
||||
id = "linux-arm64"
|
||||
title = "Horizon on Linux ARM64"
|
||||
status = "later"
|
||||
area = "platforms"
|
||||
plain = "Game and launcher builds for 64-bit ARM Linux computers."
|
||||
dev = """Issue #1709: aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM \
|
||||
machine with its Vulkan driver. The server build already drops audio and controllers \
|
||||
(`--no-default-features`)."""
|
||||
links = ["#1709"]
|
||||
|
||||
[[item]]
|
||||
id = "opengl-fallback"
|
||||
title = "OpenGL for old computers"
|
||||
status = "later"
|
||||
area = "platforms"
|
||||
plain = "A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL."
|
||||
dev = """Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL \
|
||||
backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like \
|
||||
the DirectX 12 fallback that already ships."""
|
||||
links = ["docs/graphics.md"]
|
||||
|
||||
[[item]]
|
||||
id = "phones-and-browser"
|
||||
title = "Android, iOS and the browser"
|
||||
status = "later"
|
||||
area = "platforms"
|
||||
plain = """Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the \
|
||||
first pieces."""
|
||||
dev = """No official build. Android was first closed as not planned, with touch controls, a mobile interface, \
|
||||
packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a \
|
||||
WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` \
|
||||
compiling on iOS (#1813) and helper-thread limits on Android (#1751). Preparing without Java (#1750) matters \
|
||||
here too. Horizon ships none of Mojang's files on any platform."""
|
||||
links = ["#1813", "#1751"]
|
||||
|
||||
[[item]]
|
||||
id = "video-series"
|
||||
title = "A video series about Horizon"
|
||||
status = "later"
|
||||
area = "community"
|
||||
plain = """A series of videos about how Horizon is made and what is new, with each upload announced on Discord."""
|
||||
dev = """Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the \
|
||||
project: a script, edits, titles and a thumbnail per video."""
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------- Help wanted
|
||||
|
||||
[[item]]
|
||||
|
||||
@@ -291,6 +291,19 @@ def entities():
|
||||
cmd('/data get entity @e[tag=root,limit=1] Passengers[0].Health'),
|
||||
cmd('/data get entity @e[tag=root,limit=1] Passengers[0].id'),
|
||||
cmd('/kill @e[tag=root]'),
|
||||
# Saved attribute bases are read for every mob, then Health or the max
|
||||
# health when there is none (LivingEntity.readAdditionalSaveData, #1483).
|
||||
cmd('/summon minecraft:cow 4.5 210 8.5 {Tags:["attr"],NoAI:1b,NoGravity:1b,'
|
||||
'attributes:[{id:"minecraft:max_health",base:40.0d},{id:"minecraft:movement_speed",base:0.5d}]}'),
|
||||
cmd('/data get entity @e[tag=attr,limit=1] Health'),
|
||||
cmd('/attribute @e[tag=attr,limit=1] minecraft:max_health base get'),
|
||||
cmd('/attribute @e[tag=attr,limit=1] minecraft:movement_speed base get'),
|
||||
cmd('/kill @e[tag=attr]'),
|
||||
cmd('/summon minecraft:zombie 4.5 210 8.5 {Tags:["attr"],NoAI:1b,NoGravity:1b,Health:7.0f,'
|
||||
'attributes:[{id:"minecraft:attack_damage",base:9.0d}]}'),
|
||||
cmd('/data get entity @e[tag=attr,limit=1] Health'),
|
||||
cmd('/attribute @e[tag=attr,limit=1] minecraft:attack_damage base get'),
|
||||
cmd('/kill @e[tag=attr]'),
|
||||
*setup('/kill @e[type=minecraft:armor_stand]'),
|
||||
{'op': 'tick', 'n': 25},
|
||||
cmd('/execute if entity @e[tag=h]'),
|
||||
@@ -327,6 +340,31 @@ def riding_teleport():
|
||||
'/tp @s 8.5 230 8.5 0 0')
|
||||
|
||||
|
||||
def mob_riding():
|
||||
"""Mobs mount and dismount mobs with /ride, NBT passengers included (#1520)."""
|
||||
rider, vehicle = '@e[tag=ride_r,limit=1]', '@e[tag=ride_v,limit=1]'
|
||||
return [
|
||||
*setup('/summon minecraft:cow 4.5 220 12.5 {NoAI:1b,NoGravity:1b,Tags:["ride_v"]}',
|
||||
'/summon minecraft:pig 6.5 220 12.5 {NoAI:1b,NoGravity:1b,Tags:["ride_r"]}'),
|
||||
cmd(f'/ride {rider} dismount'),
|
||||
cmd(f'/ride {rider} mount @s'),
|
||||
cmd(f'/ride {rider} mount {rider}'),
|
||||
cmd(f'/ride {rider} mount {vehicle}'),
|
||||
cmd(f'/ride {rider} mount {vehicle}'),
|
||||
cmd(f'/ride {vehicle} mount {rider}'),
|
||||
cmd('/execute as @e[tag=ride_r] on vehicle run say carrying'),
|
||||
cmd(f'/ride {rider} dismount'),
|
||||
cmd(f'/data get entity {rider} Pos'),
|
||||
cmd('/execute as @e[tag=ride_r] on vehicle run say still carrying'),
|
||||
*setup('/kill @e[tag=ride_r]', '/kill @e[tag=ride_v]'),
|
||||
*setup('/summon minecraft:cow 4.5 220 12.5 {NoAI:1b,NoGravity:1b,Tags:["ride_v"],'
|
||||
'Passengers:[{id:"minecraft:pig",NoAI:1b,NoGravity:1b,Tags:["ride_r"]}]}'),
|
||||
cmd(f'/ride {rider} dismount'),
|
||||
cmd(f'/data get entity {rider} Pos'),
|
||||
*setup('/kill @e[tag=ride_r]', '/kill @e[tag=ride_v]'),
|
||||
]
|
||||
|
||||
|
||||
def scores():
|
||||
return [
|
||||
cmd('/scoreboard objectives add harness dummy'),
|
||||
@@ -1017,6 +1055,7 @@ def groups():
|
||||
('blocks and border', world_blocks()),
|
||||
('entities', entities()),
|
||||
('riding teleport', riding_teleport()),
|
||||
('mob riding', mob_riding()),
|
||||
('scores, teams and bossbars', scores()),
|
||||
('execute', execute()),
|
||||
('levels', levels()),
|
||||
|
||||
@@ -6252,6 +6252,56 @@ CHECKS += ['crystals']
|
||||
CHECK_FNS.update(crystals=check_crystals)
|
||||
|
||||
|
||||
def check_boat_dismount_pose(host, guest, out):
|
||||
"""Removing a boat detaches its mobs on the server and every observing client."""
|
||||
x, y, z = map(math.floor, pos_of(host))
|
||||
bx, bz = x + 3.5, z + 3.5
|
||||
tag = 'boat_dismount_pose'
|
||||
host('command', command='gamemode creative')
|
||||
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 8} {y - 1} {z + 8} minecraft:stone')
|
||||
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 8} {y + 4} {z + 8} minecraft:air')
|
||||
host('command', command=f'summon minecraft:oak_boat {bx} {y} {bz} {{Tags:["{tag}"]}}')
|
||||
for _ in range(2):
|
||||
host('command', command=f'summon minecraft:husk {bx} {y} {bz} {{NoAI:1b,Tags:["{tag}"]}}')
|
||||
host.wait(5)
|
||||
try:
|
||||
host.wait(20)
|
||||
boat = next(o for o in host('status')['objects']
|
||||
if o['kind'].endswith('oak_boat') and math.dist(o['position'], [bx, y, bz]) < 2)
|
||||
riders = [m['id'] for m in host.raw('mobs', 64)['near']
|
||||
if m['kind'] == 'minecraft:husk' and m['mount'] == boat['id']]
|
||||
assert len(riders) == 2, f'the boat did not collect both husks: {riders}'
|
||||
for game in (host, guest):
|
||||
game.wait(10)
|
||||
for rider in riders:
|
||||
m = mob_seen(game, rider)
|
||||
assert m and m['mount'] == boat['id'] and m['passenger'], f'{game.name} did not see seated husk {rider}: {m}'
|
||||
game.raw('look-at', bx, y + 1, bz)
|
||||
game.shot(out, 'boat-passengers-seated')
|
||||
host('command', command=f'tp @s {bx - 3} {y} {bz} 0 0')
|
||||
host.wait(5)
|
||||
# Aim at the near rim, below the riders, so their boxes cannot
|
||||
# intercept the creative player's one hit that destroys the boat.
|
||||
host.raw('look-at', bx - 0.55, y + 0.1, bz)
|
||||
host.raw('mouse', 'left', 'click')
|
||||
seen = {}
|
||||
for game in (host, guest):
|
||||
game.wait(20)
|
||||
assert all(o['id'] != boat['id'] for o in game('status')['objects']), f'{game.name} still sees the boat'
|
||||
seen[game.name] = [mob_seen(game, rider) for rider in riders]
|
||||
game.raw('look-at', bx, y + 1, bz)
|
||||
game.shot(out, 'boat-passengers-dismounted')
|
||||
for m in seen[game.name]:
|
||||
assert m and m['mount'] is None and not m['passenger'], f'{game.name} still draws a removed boat\'s passenger: {m}'
|
||||
return {name: [{k: m[k] for k in ('id', 'mount', 'passenger')} for m in mobs] for name, mobs in seen.items()}
|
||||
finally:
|
||||
host('command', command=f'kill @e[tag={tag}]')
|
||||
|
||||
|
||||
CHECKS += ['boat-dismount-pose']
|
||||
CHECK_FNS['boat-dismount-pose'] = check_boat_dismount_pose
|
||||
|
||||
|
||||
def check_sprint_fov(host, guest, out):
|
||||
"""Releasing Ctrl must preserve a sprint until W is released, then reset FOV.
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""What a player holds on the cursor when they die, on a dedicated server, against vanilla's, seen by a vanilla client.
|
||||
|
||||
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world
|
||||
vanilla generated first, with Alice an operator in survival and keep_inventory off. LocatorBarClient (a player made
|
||||
of vanilla's protocol code) is given seven diamonds, picks them up onto its inventory menu's cursor (a PICKUP click)
|
||||
ten blocks from the spawn, runs /kill on itself, waits on the death screen and respawns (PERFORM_RESPAWN). Twenty
|
||||
ticks after dying vanilla removes the dead player (LivingEntity.tickDeath), and Player.remove's
|
||||
inventoryMenu.removed drops the cursor's stack where it died. Alice then asks whether an item lies around the death
|
||||
site; the reply, and the inventory slots, respawns and teleports she is sent from the death on, are compared
|
||||
exactly.
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
from . import test_locator_bar_parity as locator
|
||||
except ImportError:
|
||||
import test_locator_bar_parity as locator
|
||||
|
||||
NAME = 'cursor-death-parity'
|
||||
# server_dir and generate_world write these server.properties.
|
||||
locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival')
|
||||
SCRIPT = [
|
||||
('setup', ['cmd Alice gamerule keep_inventory false', 'cmd Alice clear @s',
|
||||
'cmd Alice give @s minecraft:diamond 7', 'cmd Alice tp @s 10.5 -60 0.5']),
|
||||
# Hotbar slot 0 is the inventory menu's slot 36.
|
||||
('pickup', ['pickup Alice 36']),
|
||||
('death', ['cmd Alice kill @s']),
|
||||
('respawn', ['respawn Alice']),
|
||||
('check', ['chat on', 'cmd Alice execute if entity @e[type=minecraft:item,x=10.5,y=-60,z=0.5,distance=..4]']),
|
||||
]
|
||||
|
||||
|
||||
def shown(lines):
|
||||
"""Alice's slots, respawns, teleports and chat from the death on."""
|
||||
return [line for line in lines if len(parts := line.split(' ', 3)) >= 3
|
||||
and parts[1] in ('step=death', 'step=respawn', 'step=check')
|
||||
and parts[2] in ('slot', 'inventory', 'respawn', 'teleport', 'chat')]
|
||||
|
||||
|
||||
def check(vanilla, _):
|
||||
if not any(l.startswith('Alice step=check chat') for l in vanilla):
|
||||
return 'vanilla answered no check command'
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
lines = locator.one_player(NAME, SCRIPT, shown, check, 'A player dying with a stack on the inventory menu cursor '
|
||||
'(keep_inventory off) and respawning on a dedicated server: the stack is dropped '
|
||||
'where it died, not kept.')
|
||||
print(f'cursor death: {len(lines)} lines identical')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except AssertionError as error:
|
||||
sys.exit(f'{NAME}: {error}')
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Respawning in a hardcore world on a dedicated server, against vanilla's, seen by a vanilla client.
|
||||
|
||||
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one hardcore
|
||||
flat world vanilla generated first, with Alice an operator in survival. LocatorBarClient (a player made of
|
||||
vanilla's protocol code) joins, runs /kill on itself and presses the death screen's button
|
||||
(ServerboundClientCommandPacket PERFORM_RESPAWN). Vanilla's ServerGamePacketListenerImpl.handleClientCommand
|
||||
respawns the player, then makes it a spectator because the server is hardcore. The game mode changes and respawns
|
||||
Alice is sent in the death and respawn steps are compared exactly.
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
from . import test_locator_bar_parity as locator
|
||||
except ImportError:
|
||||
import test_locator_bar_parity as locator
|
||||
|
||||
NAME = 'hardcore-respawn-parity'
|
||||
# server_dir and generate_world write these server.properties.
|
||||
locator.PROPERTIES = dict(locator.PROPERTIES, hardcore='true', gamemode='survival')
|
||||
SCRIPT = [
|
||||
('death', ['cmd Alice kill @s']),
|
||||
('respawn', ['respawn Alice']),
|
||||
]
|
||||
|
||||
|
||||
def shown(lines):
|
||||
"""Alice's game mode changes and respawns in the death and respawn steps."""
|
||||
return [line for line in lines if len(parts := line.split(' ', 3)) >= 3
|
||||
and parts[1] in ('step=death', 'step=respawn') and parts[2] in ('gamemode', 'respawn')]
|
||||
|
||||
|
||||
def check(vanilla, _):
|
||||
if 'Alice step=respawn gamemode 3' not in vanilla:
|
||||
return 'vanilla did not make the respawned player a spectator'
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
lines = locator.one_player(NAME, SCRIPT, shown, check, 'A player dying and respawning in a hardcore world on a '
|
||||
'dedicated server: the respawn and the game mode it is sent (spectator).')
|
||||
print(f'hardcore respawn: {len(lines)} lines identical')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except AssertionError as error:
|
||||
sys.exit(f'{NAME}: {error}')
|
||||
@@ -205,12 +205,13 @@ def run_side(label, server_command, client_command, build, world):
|
||||
return out.read_text(encoding='utf-8').splitlines()
|
||||
|
||||
|
||||
def main():
|
||||
def sides(name):
|
||||
"""The vanilla and Horizon dedicated servers and the LocatorBarClient that joins them, built for harness
|
||||
`name`: (lock, build directory, vanilla server, Horizon server, client) commands."""
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
build = lab.LOCAL / NAME
|
||||
build = lab.LOCAL / name
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
report = build / 'report.json'
|
||||
report.unlink(missing_ok=True)
|
||||
(build / 'report.json').unlink(missing_ok=True)
|
||||
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java'
|
||||
classes = build / 'classes'
|
||||
shutil.rmtree(classes, ignore_errors=True)
|
||||
@@ -228,7 +229,74 @@ def main():
|
||||
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
|
||||
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
|
||||
client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient']
|
||||
return lock, build, vanilla_server, horizon_server, client
|
||||
|
||||
|
||||
def one_player(name, script, shown, check, scope, step_seconds=4.0):
|
||||
"""A harness of one player, Alice: on each server she joins, then runs `script`, a list of (step, client
|
||||
actions) `step_seconds` apart. The lines `shown(transcript)` keeps must match vanilla's exactly, and
|
||||
`check(vanilla's shown lines, vanilla's transcript)` returns why vanilla's run is no proof (the script did
|
||||
not run), or None."""
|
||||
lock, build, vanilla_server, horizon_server, client = sides(name)
|
||||
|
||||
def run(label, server_command, world):
|
||||
directory = build / label
|
||||
port = server_dir(directory, world)
|
||||
out = build / f'{label}.txt'
|
||||
out.unlink(missing_ok=True)
|
||||
server = Server(server_command, directory, label)
|
||||
try:
|
||||
server.wait_for(r'Done \(')
|
||||
with (build / f'{label}-client.log').open('w') as log:
|
||||
process = subprocess.Popen([*map(str, client), '127.0.0.1', str(port), str(out)], cwd=lab.ROOT,
|
||||
stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, text=True)
|
||||
|
||||
def tell(line):
|
||||
process.stdin.write(line + '\n')
|
||||
process.stdin.flush()
|
||||
try:
|
||||
for line in ['players on', 'state on', 'step joins', 'join Alice']:
|
||||
tell(line)
|
||||
server.wait_for(r'Alice joined the game', timeout=120)
|
||||
time.sleep(step_seconds)
|
||||
for step, actions in script:
|
||||
tell(f'step {step}')
|
||||
for action in actions:
|
||||
tell(action)
|
||||
time.sleep(step_seconds)
|
||||
tell('end')
|
||||
process.wait(timeout=60)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
finally:
|
||||
server.stop()
|
||||
if not out.exists():
|
||||
raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)')
|
||||
return out.read_text(encoding='utf-8').splitlines()
|
||||
|
||||
world = generate_world(vanilla_server, build)
|
||||
vanilla_all = run('vanilla', vanilla_server, world)
|
||||
horizon_all = run('horizon', horizon_server, world)
|
||||
errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l]
|
||||
vanilla, horizon = shown(vanilla_all), shown(horizon_all)
|
||||
if errors or vanilla != horizon:
|
||||
import difflib
|
||||
diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm=''))
|
||||
raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n'
|
||||
+ '\n'.join(errors[:10]) + '\n' + diff[:6000])
|
||||
if (why := check(vanilla, vanilla_all)) is not None:
|
||||
raise AssertionError(f'{why}: the script did not run (see {build})')
|
||||
lab.write_json(build / 'report.json', dict(
|
||||
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
|
||||
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
||||
lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), scope=scope))
|
||||
return vanilla
|
||||
|
||||
|
||||
def main():
|
||||
lock, build, vanilla_server, horizon_server, client = sides(NAME)
|
||||
report = build / 'report.json'
|
||||
world = generate_world(vanilla_server, build)
|
||||
vanilla = run_side('vanilla', vanilla_server, client, build, world)
|
||||
horizon = run_side('horizon', horizon_server, client, build, world)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""An ender pearl in flight when its thrower dies, on a dedicated server, against vanilla's, seen by a vanilla client.
|
||||
|
||||
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world
|
||||
vanilla generated first, with Alice an operator in survival. LocatorBarClient (a player made of vanilla's protocol
|
||||
code) throws an ender pearl straight up (ServerboundUseItemPacket), runs /kill on itself while the pearl flies,
|
||||
presses the death screen's button (PERFORM_RESPAWN) and waits for the pearl to come down, with the rule
|
||||
ender_pearls_vanish_on_death true: ThrownEnderpearl.tick discards a dead owner's pearl, so the respawned player is
|
||||
never teleported. The teleports, game mode changes and respawns Alice is sent from the throw on are compared
|
||||
exactly.
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
from . import test_locator_bar_parity as locator
|
||||
except ImportError:
|
||||
import test_locator_bar_parity as locator
|
||||
|
||||
NAME = 'pearl-death-parity'
|
||||
# server_dir and generate_world write these server.properties.
|
||||
locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival')
|
||||
SCRIPT = [
|
||||
('setup', ['cmd Alice gamerule ender_pearls_vanish_on_death true', 'cmd Alice give @s minecraft:ender_pearl']),
|
||||
# Straight up: it comes down about four seconds later, near where it was thrown.
|
||||
('throw', ['use Alice -90', 'cmd Alice kill @s']),
|
||||
('respawn', ['respawn Alice']),
|
||||
('landed', []),
|
||||
]
|
||||
|
||||
|
||||
def shown(lines):
|
||||
"""Alice's teleports, game mode changes and respawns from the throw on."""
|
||||
steps = {f'step={step}' for step, _ in SCRIPT if step != 'setup'}
|
||||
return [line for line in lines if len(parts := line.split(' ', 3)) >= 3 and parts[1] in steps
|
||||
and parts[2] in ('teleport', 'gamemode', 'respawn')]
|
||||
|
||||
|
||||
def check(vanilla, vanilla_all):
|
||||
killed = any(l.startswith('Alice step=throw killed') for l in vanilla_all)
|
||||
if not killed or 'Alice step=respawn respawn minecraft:overworld' not in vanilla:
|
||||
return 'vanilla did not kill and respawn Alice'
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
lines = locator.one_player(NAME, SCRIPT, shown, check, 'An ender pearl in flight when its thrower dies and '
|
||||
'respawns on a dedicated server, with ender_pearls_vanish_on_death true: the pearl '
|
||||
'vanishes and never teleports the player.')
|
||||
print(f'pearl death: {len(lines)} lines identical')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except AssertionError as error:
|
||||
sys.exit(f'{NAME}: {error}')
|
||||
+654
-9
@@ -59,10 +59,22 @@
|
||||
"id": "launcher",
|
||||
"name": "Launcher"
|
||||
},
|
||||
{
|
||||
"id": "platforms",
|
||||
"name": "Platforms"
|
||||
},
|
||||
{
|
||||
"id": "extras",
|
||||
"name": "Horizon extras"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"name": "AI and agents"
|
||||
},
|
||||
{
|
||||
"id": "community",
|
||||
"name": "Community"
|
||||
},
|
||||
{
|
||||
"id": "project",
|
||||
"name": "Code and tools"
|
||||
@@ -255,6 +267,73 @@
|
||||
],
|
||||
"ongoing": true
|
||||
},
|
||||
{
|
||||
"id": "native-shaders",
|
||||
"title": "Horizon's own shader format and kit",
|
||||
"status": "now",
|
||||
"area": "rendering",
|
||||
"plain": "Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.",
|
||||
"dev": "`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes (#1820) and the tool, templates and guide (#1822). In review: the post stage running in the game with hot reload (#1861), the sky stage (#1863) and registry checks that use the game's own check (#1855). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets (docs/shaders.md, docs/api/shaders.md). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.",
|
||||
"links": [
|
||||
"#1820",
|
||||
"#1822",
|
||||
"#1861",
|
||||
"#1863",
|
||||
"#1855",
|
||||
"docs/shaders.md",
|
||||
"docs/api/shaders.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "server-mods",
|
||||
"title": "Mods that run on the server",
|
||||
"status": "now",
|
||||
"area": "mods",
|
||||
"plain": "Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.",
|
||||
"dev": "API 0.3, experimental first (#1833, in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). docs/api/mods.md, docs/mods.md.",
|
||||
"links": [
|
||||
"#1833",
|
||||
"docs/api/mods.md",
|
||||
"docs/mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mod-screens",
|
||||
"title": "Screens, input and drawing for mods",
|
||||
"status": "now",
|
||||
"area": "mods",
|
||||
"plain": "Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.",
|
||||
"dev": "Capability `ui` is in review (#1864): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').",
|
||||
"links": [
|
||||
"#1864",
|
||||
"docs/mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "modder-tools",
|
||||
"title": "Tools for people who make mods",
|
||||
"status": "now",
|
||||
"area": "mods",
|
||||
"plain": "Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.",
|
||||
"dev": "`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot (#1816) and, for server mods, scripted server events (#1837); the content template and the guides are merged (#1859, #1810, docs/api/stability.md). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.",
|
||||
"links": [
|
||||
"#1816",
|
||||
"#1837",
|
||||
"docs/api/stability.md",
|
||||
"website/docs/test-a-mod.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pregen",
|
||||
"title": "Pre-generate an area with /pregen",
|
||||
"status": "now",
|
||||
"area": "servers",
|
||||
"plain": "A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.",
|
||||
"dev": "Generates, lights and saves an area before anyone walks there (#1805, a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.",
|
||||
"links": [
|
||||
"#1805"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "microsoft-sign-in",
|
||||
"title": "Microsoft sign-in (coming soon)",
|
||||
@@ -286,8 +365,8 @@
|
||||
"title": "Fresh benchmarks against vanilla",
|
||||
"status": "next",
|
||||
"area": "performance",
|
||||
"plain": "New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, on the website's Benchmarks page.",
|
||||
"dev": "Redo the comparison after the current performance work (#1073); reliability, coverage and presentation (#1346); a scene must count as loaded only once its first chunk arrives (#1749). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` (docs/benchmark.md).",
|
||||
"plain": "New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, with a history that shows what each release changed.",
|
||||
"dev": "Redo the comparison after the current performance work (#1073); reliability, coverage and presentation (#1346); a scene must count as loaded only once its first chunk arrives (#1749). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` (docs/benchmark.md).",
|
||||
"links": [
|
||||
"#1073",
|
||||
"#1346",
|
||||
@@ -314,7 +393,7 @@
|
||||
"id": "intel-mac",
|
||||
"title": "Horizon on Intel Macs",
|
||||
"status": "next",
|
||||
"area": "launcher",
|
||||
"area": "platforms",
|
||||
"plain": "Builds of the game and the launcher for Macs with an Intel processor.",
|
||||
"dev": "x86_64 game and launcher builds, picked per processor (#1171). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: #1161, #1792.",
|
||||
"links": [
|
||||
@@ -323,13 +402,328 @@
|
||||
"#1792"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "world-entry",
|
||||
"title": "Faster world creation and entry on small CPUs",
|
||||
"status": "next",
|
||||
"area": "performance",
|
||||
"plain": "Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 chunks can make you wait minutes for the first terrain.",
|
||||
"dev": "Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does (#1760 and #1778, in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` (docs/benchmark.md, docs/profiling.md).",
|
||||
"links": [
|
||||
"docs/benchmark.md",
|
||||
"docs/profiling.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "server-tick",
|
||||
"title": "Servers that hold 20 TPS with 50 to 100 players",
|
||||
"status": "next",
|
||||
"area": "performance",
|
||||
"plain": "Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.",
|
||||
"dev": "Measured against vanilla on equal work (docs/server.md, Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.",
|
||||
"links": [
|
||||
"docs/server.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "low-end-mode",
|
||||
"title": "Low-end computers: Steam Deck, old Macs, 8 GB",
|
||||
"status": "next",
|
||||
"area": "performance",
|
||||
"plain": "Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.",
|
||||
"dev": "Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.",
|
||||
"links": [
|
||||
"docs/benchmark.md",
|
||||
"website/docs/performance.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "upscaling",
|
||||
"title": "Native upscaling",
|
||||
"status": "next",
|
||||
"area": "performance",
|
||||
"plain": "Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain frames on weaker graphics cards. An optional graphics setting.",
|
||||
"dev": "A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue #1403.",
|
||||
"links": [
|
||||
"#1403"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "targeted-optimisations",
|
||||
"title": "A batch of targeted optimisations",
|
||||
"status": "next",
|
||||
"area": "performance",
|
||||
"plain": "Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.",
|
||||
"dev": "Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes (#1461). Profile first, keep what pays, show before and after numbers.",
|
||||
"links": [
|
||||
"#1461",
|
||||
"docs/profiling.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "convert-to-rust",
|
||||
"title": "A Convert to Rust button for what isn't rewritten yet",
|
||||
"status": "next",
|
||||
"area": "modpacks",
|
||||
"plain": "When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.",
|
||||
"dev": "Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run (docs/modpacks.md, docs/mod-browser.md).",
|
||||
"links": [
|
||||
"docs/modpacks.md",
|
||||
"docs/mod-browser.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "code-mods",
|
||||
"title": "Mods with code: machines, magic, mobs",
|
||||
"status": "next",
|
||||
"area": "modpacks",
|
||||
"plain": "Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.",
|
||||
"dev": "333 of the 496 mods in a big pack (ATM10) contain code. Issue #1854 ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft (#1189, see 'Machines and blocks with inventories'). The path isn't started yet (docs/modpacks.md).",
|
||||
"links": [
|
||||
"#1854",
|
||||
"docs/modpacks.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "datapack-browser",
|
||||
"title": "Data packs in the mod browser",
|
||||
"status": "next",
|
||||
"area": "modpacks",
|
||||
"plain": "Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.",
|
||||
"dev": "A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds (docs/mod-browser.md).",
|
||||
"links": [
|
||||
"docs/mod-browser.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prepare-without-java",
|
||||
"title": "Prepare a version without Java",
|
||||
"status": "next",
|
||||
"area": "servers",
|
||||
"plain": "Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.",
|
||||
"dev": "Today `horizon-prepare` runs Mojang's exporters with Java. Issue #1750 proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step (docs/server.md).",
|
||||
"links": [
|
||||
"#1750",
|
||||
"docs/server.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "server-admin",
|
||||
"title": "Server console, bans and a full admin API",
|
||||
"status": "next",
|
||||
"area": "servers",
|
||||
"plain": "Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.",
|
||||
"dev": "`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. docs/server.md, docs/api/server.md.",
|
||||
"links": [
|
||||
"docs/server.md",
|
||||
"docs/api/server.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hosting-polish",
|
||||
"title": "Hosting polish: Windows service, safe updates",
|
||||
"status": "next",
|
||||
"area": "servers",
|
||||
"plain": "Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each player's state, and a changed view distance or message without a restart.",
|
||||
"dev": "A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state (#1808, a contributor's draft, in review). docs/hosting.md, docs/server.md.",
|
||||
"links": [
|
||||
"#1808",
|
||||
"docs/hosting.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tailscale-hosting",
|
||||
"title": "Guided hosting with Tailscale and an invite button",
|
||||
"status": "next",
|
||||
"area": "servers",
|
||||
"plain": "In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.",
|
||||
"dev": "On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in docs/server.md.",
|
||||
"links": [
|
||||
"docs/hosting.md",
|
||||
"docs/server.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "docker-image",
|
||||
"title": "An official Docker image for servers",
|
||||
"status": "next",
|
||||
"area": "servers",
|
||||
"plain": "An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.",
|
||||
"dev": "The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it (docs/hosting.md, section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.",
|
||||
"links": [
|
||||
"docs/hosting.md",
|
||||
"docs/server.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "one-single-player-path",
|
||||
"title": "One single-player path: remove the in-frame world",
|
||||
"status": "next",
|
||||
"area": "gameplay",
|
||||
"plain": "Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.",
|
||||
"dev": "`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and docs/server-levels.md.",
|
||||
"links": [
|
||||
"docs/server-levels.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "villages-combat-parity",
|
||||
"title": "Villages and combat, exactly as in vanilla",
|
||||
"status": "next",
|
||||
"area": "gameplay",
|
||||
"plain": "Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.",
|
||||
"dev": "Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers (#1639 is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness (docs/rust-parity.md).",
|
||||
"links": [
|
||||
"#1639",
|
||||
"docs/rust-parity.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "audio-device",
|
||||
"title": "Choose the audio output device",
|
||||
"status": "next",
|
||||
"area": "gameplay",
|
||||
"plain": "Pick the speakers or headphones Horizon plays through, in Music & Sounds.",
|
||||
"dev": "An output device list in Music & Sounds, falling back to the system default. #1035 started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.",
|
||||
"links": [
|
||||
"#1035"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mod-world-items",
|
||||
"title": "Mods that read and change the world",
|
||||
"status": "next",
|
||||
"area": "mods",
|
||||
"plain": "Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.",
|
||||
"dev": "After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. docs/api/mods.md ('Not there yet').",
|
||||
"links": [
|
||||
"docs/api/mods.md",
|
||||
"docs/modpacks.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "machines-api",
|
||||
"title": "Machines and blocks with inventories (API 0.4)",
|
||||
"status": "next",
|
||||
"area": "mods",
|
||||
"plain": "Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.",
|
||||
"dev": "Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft (#1189) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges (docs/modpacks.md, docs/api/stability.md).",
|
||||
"links": [
|
||||
"#1189",
|
||||
"docs/modpacks.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mod-languages",
|
||||
"title": "Lua and other languages for mods",
|
||||
"status": "next",
|
||||
"area": "mods",
|
||||
"plain": "Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.",
|
||||
"dev": "Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. docs/api/mods.md ('Ways in').",
|
||||
"links": [
|
||||
"docs/api/mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "shader-pack-examples",
|
||||
"title": "Well-known shader looks, rewritten for Horizon",
|
||||
"status": "next",
|
||||
"area": "rendering",
|
||||
"plain": "Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.",
|
||||
"dev": "The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`crates/horizon-shader-api/packs/golden_hour`).",
|
||||
"links": [
|
||||
"docs/shaders.md",
|
||||
"crates/horizon-shader-api/packs/golden_hour"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "beta-label",
|
||||
"title": "A visible beta label",
|
||||
"status": "next",
|
||||
"area": "launcher",
|
||||
"plain": "A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.",
|
||||
"dev": "Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number (docs/versioning.md).",
|
||||
"links": [
|
||||
"docs/versioning.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wayland-polish",
|
||||
"title": "Wayland and Hyprland polish",
|
||||
"status": "next",
|
||||
"area": "platforms",
|
||||
"plain": "Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.",
|
||||
"dev": "Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it (#1866). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay (#1007, closed). Checked on a real Wayland session.",
|
||||
"links": [
|
||||
"#1866"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agent-control-protocol",
|
||||
"title": "A versioned agent-control protocol",
|
||||
"status": "next",
|
||||
"area": "ai",
|
||||
"plain": "A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.",
|
||||
"dev": "`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: docs/agent-control.md, docs/api/agent-control.md.",
|
||||
"links": [
|
||||
"docs/agent-control.md",
|
||||
"docs/api/agent-control.md",
|
||||
"docs/api/stability.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "own-agents-porting",
|
||||
"title": "Port mods with your own AI agent",
|
||||
"status": "next",
|
||||
"area": "ai",
|
||||
"plain": "You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.",
|
||||
"dev": "A player runs their own agent locally with `horizon-ctl` and the MCP servers (docs/agent-control.md), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (website/docs/agents.md, docs/modpacks.md) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.",
|
||||
"links": [
|
||||
"website/docs/agents.md",
|
||||
"docs/modpacks.md",
|
||||
"docs/agent-control.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x-account",
|
||||
"title": "Horizon on X (coming soon)",
|
||||
"status": "next",
|
||||
"area": "community",
|
||||
"plain": "Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.",
|
||||
"dev": "Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist (roadmap/README.md, 'The journal'). Waiting for the account itself.",
|
||||
"links": [
|
||||
"roadmap/README.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agents-app",
|
||||
"title": "Pipeline protection and a GitHub App for agents",
|
||||
"status": "next",
|
||||
"area": "project",
|
||||
"plain": "Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.",
|
||||
"dev": "`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers (#1015); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on (docs/security.md).",
|
||||
"links": [
|
||||
"docs/security.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "signed-installers",
|
||||
"title": "Signed updates everywhere, installers included",
|
||||
"status": "next",
|
||||
"area": "project",
|
||||
"plain": "Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.",
|
||||
"dev": "Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned (#1011, #1014). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship (docs/security.md).",
|
||||
"links": [
|
||||
"docs/security.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ai-companion",
|
||||
"title": "An optional AI teammate",
|
||||
"status": "later",
|
||||
"area": "extras",
|
||||
"plain": "An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the No AI switch hides it completely.",
|
||||
"dev": "Draft pull requests: the companion (#1068), its safety modes and their evaluation (#1069), and a route that finishes the game (#1070). Local models first; it ships only once the safety evaluation passes.",
|
||||
"area": "ai",
|
||||
"plain": "An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, runs on your computer first, and exists only if you turn it on. The No AI switch hides it.",
|
||||
"dev": "Draft pull requests: the companion (#1068), its safety modes and their evaluation (#1069) and a route that finishes the game (#1070). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.",
|
||||
"links": [
|
||||
"#1068",
|
||||
"#1069",
|
||||
@@ -338,15 +732,266 @@
|
||||
},
|
||||
{
|
||||
"id": "new-versions",
|
||||
"title": "Following new Minecraft versions",
|
||||
"title": "Every Minecraft version, prepared automatically",
|
||||
"status": "later",
|
||||
"area": "gameplay",
|
||||
"plain": "When Mojang releases a new version of the game, Horizon follows it, and every system is checked against the new version again.",
|
||||
"dev": "The port pipeline detects a release, diffs its classes against 26.3 and writes a port report (docs/version-ports.md); the harnesses then run against the new version.",
|
||||
"plain": "Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, Horizon follows it, and every system is checked against it again.",
|
||||
"dev": "The port pipeline (docs/version-ports.md, `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.",
|
||||
"links": [
|
||||
"docs/version-ports.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "exact-far-terrain",
|
||||
"title": "Exact far terrain from real chunks",
|
||||
"status": "later",
|
||||
"area": "performance",
|
||||
"plain": "Far terrain drawn from the world you really built and explored, caves and builds included, not only a preview from the seed.",
|
||||
"dev": "Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is #718.",
|
||||
"links": [
|
||||
"#718"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "older-jars",
|
||||
"title": "Older Fabric and Forge mods",
|
||||
"status": "later",
|
||||
"area": "modpacks",
|
||||
"plain": "Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over like newer ones.",
|
||||
"dev": "A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars (docs/mod-browser.md).",
|
||||
"links": [
|
||||
"docs/mod-browser.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "modpack-own-packs",
|
||||
"title": "A modpack's own resource and shader packs",
|
||||
"status": "later",
|
||||
"area": "modpacks",
|
||||
"plain": "Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.",
|
||||
"dev": "Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter (docs/modpacks.md).",
|
||||
"links": [
|
||||
"docs/modpacks.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "whole-modpacks",
|
||||
"title": "Whole modpacks, such as ATM10, rewritten",
|
||||
"status": "later",
|
||||
"area": "modpacks",
|
||||
"plain": "Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: the work above comes first.",
|
||||
"dev": "The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of its 496 mods are carried at least in part (docs/modpacks.md). It depends on 'Mods with code' and the native mod API.",
|
||||
"links": [
|
||||
"docs/modpacks.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "java-mod-bridge",
|
||||
"title": "Running Java mods that aren't rewritten",
|
||||
"status": "later",
|
||||
"area": "modpacks",
|
||||
"plain": "Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.",
|
||||
"dev": "Earlier research (#527, closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.",
|
||||
"links": [
|
||||
"#527"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "alt-accounts",
|
||||
"title": "Ely.by and other sign-in providers",
|
||||
"status": "later",
|
||||
"area": "servers",
|
||||
"plain": "Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.",
|
||||
"dev": "Issue #1270 asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in (docs/microsoft-account.md).",
|
||||
"links": [
|
||||
"#1270",
|
||||
"docs/microsoft-account.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "friends-online-mode",
|
||||
"title": "Online-mode checks for Play with Friends",
|
||||
"status": "later",
|
||||
"area": "servers",
|
||||
"plain": "Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.",
|
||||
"dev": "Play with Friends joins over an encrypted tunnel with an invite code (docs/play-with-friends.md, 'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between players, with no relay run by the project.",
|
||||
"links": [
|
||||
"docs/play-with-friends.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "orbital-laser",
|
||||
"title": "Orbital Laser on the normal game",
|
||||
"status": "later",
|
||||
"area": "mods",
|
||||
"plain": "The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.",
|
||||
"dev": "A pull request on https://github.com/Stink-O/orbital-laser (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.",
|
||||
"links": [
|
||||
"https://github.com/Stink-O/orbital-laser"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "client-mod-natives",
|
||||
"title": "Native versions of popular client mods",
|
||||
"status": "later",
|
||||
"area": "mods",
|
||||
"plain": "Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.",
|
||||
"dev": "Done as extras: a mod list and menu, a minimap and a zoom (docs/extras.md). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.",
|
||||
"links": [
|
||||
"docs/extras.md",
|
||||
"docs/mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "create-physics",
|
||||
"title": "Create-style physics as a native mod",
|
||||
"status": "later",
|
||||
"area": "mods",
|
||||
"plain": "Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.",
|
||||
"dev": "Issue #805 (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.",
|
||||
"links": [
|
||||
"#805"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "proximity-voice",
|
||||
"title": "Proximity voice chat as a native mod",
|
||||
"status": "later",
|
||||
"area": "mods",
|
||||
"plain": "Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.",
|
||||
"dev": "Needs server mods, mod networking and an audio interface in the mod API before anything can be built (docs/api/mods.md).",
|
||||
"links": [
|
||||
"docs/api/mods.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "light-default-pack",
|
||||
"title": "A light default shader pack",
|
||||
"status": "later",
|
||||
"area": "rendering",
|
||||
"plain": "A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players who want a nicer picture without a heavy pack.",
|
||||
"dev": "An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it.",
|
||||
"links": [
|
||||
"docs/shaders.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ray-tracing",
|
||||
"title": "Optional ray tracing",
|
||||
"status": "later",
|
||||
"area": "rendering",
|
||||
"plain": "An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water reflections and global lighting on top of the vanilla look.",
|
||||
"dev": "A spike with ray-traced sun shadows, off by default, is a draft (#1180). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.",
|
||||
"links": [
|
||||
"#1180"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hdr",
|
||||
"title": "HDR monitors",
|
||||
"status": "later",
|
||||
"area": "rendering",
|
||||
"plain": "Output for HDR monitors: brighter highlights and richer colour where your screen supports it.",
|
||||
"dev": "Issue #1441. Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.",
|
||||
"links": [
|
||||
"#1441"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "msaa-fullscreen",
|
||||
"title": "MSAA, anisotropic filtering, exclusive fullscreen",
|
||||
"status": "later",
|
||||
"area": "rendering",
|
||||
"plain": "More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.",
|
||||
"dev": "Not implemented (docs/graphics.md). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.",
|
||||
"links": [
|
||||
"docs/graphics.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "video-studio",
|
||||
"title": "A complete video studio inside the game",
|
||||
"status": "later",
|
||||
"area": "extras",
|
||||
"plain": "Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and finish the video. One of Horizon's original goals.",
|
||||
"dev": "Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket (docs/agent-control.md) already lets tools drive the game for scripted shots.",
|
||||
"links": [
|
||||
"docs/agent-control.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "controllers",
|
||||
"title": "Controllers: tested on real pads, and in the launcher",
|
||||
"status": "later",
|
||||
"area": "gameplay",
|
||||
"plain": "Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in the launcher too.",
|
||||
"dev": "The game side is merged (docs/controllers.md): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.",
|
||||
"links": [
|
||||
"docs/controllers.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "debug-screen",
|
||||
"title": "The full debug screen",
|
||||
"status": "later",
|
||||
"area": "gameplay",
|
||||
"plain": "Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.",
|
||||
"dev": "Today F3 shows position, chunk, light, biome and FPS (docs/native-client.md). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump (#562 asked for the F3 editor).",
|
||||
"links": [
|
||||
"#562",
|
||||
"docs/native-client.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lean-installs",
|
||||
"title": "Lean installs that use less disk",
|
||||
"status": "later",
|
||||
"area": "launcher",
|
||||
"plain": "Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the preparation needed.",
|
||||
"dev": "A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current and the previous version kept, and an automatic clean-up. Not started."
|
||||
},
|
||||
{
|
||||
"id": "linux-arm64",
|
||||
"title": "Horizon on Linux ARM64",
|
||||
"status": "later",
|
||||
"area": "platforms",
|
||||
"plain": "Game and launcher builds for 64-bit ARM Linux computers.",
|
||||
"dev": "Issue #1709: aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).",
|
||||
"links": [
|
||||
"#1709"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "opengl-fallback",
|
||||
"title": "OpenGL for old computers",
|
||||
"status": "later",
|
||||
"area": "platforms",
|
||||
"plain": "A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL.",
|
||||
"dev": "Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like the DirectX 12 fallback that already ships.",
|
||||
"links": [
|
||||
"docs/graphics.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "phones-and-browser",
|
||||
"title": "Android, iOS and the browser",
|
||||
"status": "later",
|
||||
"area": "platforms",
|
||||
"plain": "Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.",
|
||||
"dev": "No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS (#1813) and helper-thread limits on Android (#1751). Preparing without Java (#1750) matters here too. Horizon ships none of Mojang's files on any platform.",
|
||||
"links": [
|
||||
"#1813",
|
||||
"#1751"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "video-series",
|
||||
"title": "A video series about Horizon",
|
||||
"status": "later",
|
||||
"area": "community",
|
||||
"plain": "A series of videos about how Horizon is made and what is new, with each upload announced on Discord.",
|
||||
"dev": "Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the project: a script, edits, titles and a thumbnail per video."
|
||||
},
|
||||
{
|
||||
"id": "benchmark-your-pc",
|
||||
"title": "Benchmark your computer",
|
||||
|
||||
Reference in new issue
Block a user