mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
96
Commits
No files matched your search
@@ -59,6 +59,7 @@ jobs:
|
||||
- name: Smoke test
|
||||
env:
|
||||
HORIZON_LAUNCHER_NO_SELF_UPDATE: '1'
|
||||
HORIZON_GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: release builds
|
||||
# Started by hand: gh workflow run release.yml --ref <branch>
|
||||
# Builds the Windows x64 and Linux x86_64 clients and uploads the playable folders as artifacts.
|
||||
# Builds the Windows x64 and Linux x86_64 clients (with horizon-ctl) and uploads the playable folders
|
||||
# as artifacts.
|
||||
# macOS builds are made on the development machines. Linux is built here, on an older Ubuntu,
|
||||
# so the binary uses the standard loader and runs on any recent distribution (and on NixOS
|
||||
# through nix-ld): a build made on NixOS asks for a /nix/store loader no other system has.
|
||||
@@ -19,11 +20,13 @@ jobs:
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl
|
||||
- name: Only system DLLs
|
||||
# Vulkan and the rest are loaded at run time; a linked non-system DLL is a
|
||||
# "DLL not found" dialog on some players' machines.
|
||||
run: python tools/check_imports.py target/release/horizon-client.exe
|
||||
run: |
|
||||
python tools/check_imports.py target/release/horizon-client.exe
|
||||
python tools/check_imports.py target/release/horizon-ctl.exe
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
|
||||
@@ -40,9 +43,11 @@ jobs:
|
||||
- name: ALSA headers
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
|
||||
- name: Build the client
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
|
||||
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl
|
||||
- name: Standard loader and libraries only
|
||||
run: python3 tools/check_imports.py target/release/horizon-client
|
||||
run: |
|
||||
python3 tools/check_imports.py target/release/horizon-client
|
||||
python3 tools/check_imports.py target/release/horizon-ctl
|
||||
- name: Package
|
||||
run: packaging/package.sh target/release/horizon-client dist/horizon-linux-x86_64
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
Generated
+131
-4
@@ -99,6 +99,24 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -271,6 +289,15 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -494,6 +521,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error-code"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -509,6 +542,12 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
@@ -520,6 +559,12 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -641,6 +686,15 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -649,7 +703,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -658,7 +712,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -682,6 +736,7 @@ name = "horizon-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"cpal",
|
||||
"glam",
|
||||
@@ -1039,6 +1094,15 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -1123,6 +1187,18 @@ dependencies = [
|
||||
"objc2-quartz-core 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-app-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-audio-toolbox"
|
||||
version = "0.3.2"
|
||||
@@ -1213,6 +1289,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
@@ -1292,7 +1369,7 @@ checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
||||
dependencies = [
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.3",
|
||||
"objc2-app-kit",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
@@ -1427,6 +1504,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.1"
|
||||
@@ -1465,6 +1552,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2099,6 +2197,17 @@ version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
@@ -2815,7 +2924,7 @@ dependencies = [
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"objc2 0.5.3",
|
||||
"objc2-app-kit",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-ui-kit",
|
||||
"orbclient",
|
||||
@@ -2858,6 +2967,24 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.jpg" alt="Horizon: Minecraft Java Edition 26.3, rewritten from scratch in Rust" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -12,7 +12,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://playhorizon.dev"><b>Website</b></a> ·
|
||||
<a href="#download"><b>Download</b></a> ·
|
||||
<a href="#what-changed-in-v202610131"><b>What changed</b></a> ·
|
||||
<a href="#screenshots"><b>Screenshots</b></a> ·
|
||||
<a href="#project-status"><b>Parity status</b></a> ·
|
||||
<a href="docs/README.md"><b>Documentation</b></a>
|
||||
@@ -26,9 +28,18 @@ Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS:
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
|
||||
## What changed in v2026.10.13.1
|
||||
|
||||
The latest release ([notes](https://github.com/ludovic111/horizon/releases/tag/v2026.10.13.1)):
|
||||
|
||||
- **Horizon's own menus:** a new title screen, world list, options and pause menu in the launcher's style, over a live frozen landscape. The vanilla look is still available: Options → Interface → Menu style → Classic.
|
||||
- **Graphics settings, Simple and Advanced:** presets (Performance, Balanced, Quality, Ultra), or every rendering, quality, display and performance setting, each with a short description and its cost, plus a performance overlay.
|
||||
- **Much faster rendering:** for example, at render distance 8 over a village, 363 → 2,409 FPS on an RTX 4080. Render distance 32 still stutters in places.
|
||||
- **Fixed:** no sound on Linux; Linux builds that only ran on the machine they were built on; a missing DLL on Windows (the launcher no longer needs the Visual C++ runtime); memory growing and the frame rate dropping near villages (mobs were duplicated when chunks reloaded); resource preparation failing on Linux with "Missing … java-objc-bridge" ([#41](https://github.com/ludovic111/horizon/issues/41)); the hand and held item at high FOV; missing swing animations.
|
||||
|
||||
## Download
|
||||
|
||||
**[Download the Horizon launcher](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true)** for Linux x86-64, macOS Apple Silicon or Windows x64: take the newest `launcher-v*` release (currently [launcher-v0.1.1](https://github.com/ludovic111/horizon/releases/tag/launcher-v0.1.1)); the launcher then updates itself. The launcher is the way to get Horizon: it installs and updates the game, prepares its resources from your own copy of Minecraft, and starts the client or a dedicated server.
|
||||
Horizon is downloaded only through **the Horizon launcher**: get it from [playhorizon.dev](https://playhorizon.dev) or from [the launcher's releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true) (the newest `launcher-v*` release, currently [launcher-v0.1.3](https://github.com/ludovic111/horizon/releases/tag/launcher-v0.1.3)), for Linux x86-64, macOS Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its resources from your own copy of Minecraft, starts the client or a dedicated server, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition installed with the official launcher, with 26.3 played once; Python 3.10 or newer and Java 25 (a JDK, e.g. Eclipse Temurin 25) for the one-time resource preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac.
|
||||
|
||||
@@ -36,31 +47,47 @@ Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="launcher/docs/play.png" alt="The launcher's Play page"></td>
|
||||
<td width="50%"><img src="launcher/docs/versions.png" alt="The launcher's Versions page"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: v2026.10.13.1 ready to play, its summary, Client and Server, the Play button and prepared resources"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page, showing the notes of Horizon 2026.10.13.1"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
It lists Horizon's releases and installs the build for your platform, shows each release's patch notes and the roadmap, updates Horizon and itself, and doubles as a command-line tool and an MCP server. See [the launcher's README](launcher/README.md).
|
||||
It lists Horizon's releases and installs the build for your platform, shows each release's patch notes, updates Horizon and itself, and doubles as a command-line tool and an MCP server. See [the launcher's README](launcher/README.md).
|
||||
|
||||
## Screenshots
|
||||
|
||||
Captured in the Rust client itself, with vanilla assets and no mods or shaders.
|
||||
|
||||
### Menus and settings
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/media/sunset.jpg" alt="Sunset over a savanna river valley and a plains village"></td>
|
||||
<td colspan="2"><img src="docs/media/menu-title.webp" alt="Horizon's title screen: a sidebar, the Horizon title, Singleplayer and Multiplayer buttons and the last world played, over a blurred frozen landscape"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.jpg" alt="A plains village with farms, an iron golem and animals"></td>
|
||||
<td width="50%"><img src="docs/media/survival.jpg" alt="Survival at dusk: a zombie attacks near the village"></td>
|
||||
<td width="50%"><img src="docs/media/menu-worlds.webp" alt="The Singleplayer page: three worlds with their dates and game modes, and Create world and Play buttons"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
The vanilla menus are one setting away: Options → Interface → Menu style → Classic. See [Graphics settings](docs/graphics.md).
|
||||
|
||||
### In game
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/nether.jpg" alt="A crimson forest in the Nether with a zombified piglin"></td>
|
||||
<td width="50%"><img src="docs/media/night.jpg" alt="Moonrise over a torch-lit village and river"></td>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village with farms, an iron golem and animals"></td>
|
||||
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie attacks near the village"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/underwater.jpg" alt="Salmon swimming in a river"></td>
|
||||
<td width="50%"><img src="docs/media/nether.webp" alt="A crimson forest in the Nether with a zombified piglin"></td>
|
||||
<td width="50%"><img src="docs/media/night.webp" alt="Moonrise over a torch-lit village and river"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/underwater.webp" alt="Salmon swimming in a river"></td>
|
||||
<td width="50%" valign="middle">
|
||||
Vanilla world generation ported to Rust (terrain, aquifers, carvers, features, villages and the Nether), every block model with smooth lighting and biome tints, the vanilla sky and fog, mobs, the HUD, chat and advancements: everything in these frames is drawn by Horizon.
|
||||
</td>
|
||||
@@ -86,6 +113,21 @@ The [native client](docs/native-client.md) opens on a vanilla-style title screen
|
||||
- shields, maps, books, fireworks, beacons, advancements and statistics;
|
||||
- Anvil saves, joining vanilla servers, and opening a world to LAN.
|
||||
|
||||
## Agent control
|
||||
|
||||
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
|
||||
|
||||
```sh
|
||||
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
|
||||
horizon-ctl state # position, health, food, target, screen...
|
||||
horizon-ctl find-block birch_log # the nearest logs
|
||||
horizon-ctl goto -0.5 65 -2.5 --near 2.5 # walks there along a path
|
||||
horizon-ctl break -1 65 -3 # mines it; replies with the drops
|
||||
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
|
||||
```
|
||||
|
||||
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
|
||||
## Build from source
|
||||
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA development headers (`libasound2-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
@@ -105,11 +147,11 @@ cargo run --release -p horizon-client
|
||||
|
||||
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl`, and the architecture are described in [the native client guide](docs/native-client.md). The rest of the documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, playing and scripting through `horizon-ctl` and its MCP server, and the architecture are described in [the native client guide](docs/native-client.md). The rest of the documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
|
||||
## Project status
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences and the final checks are listed in the roadmap.
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
|
||||
### Java parity: 100%
|
||||
|
||||
@@ -138,19 +180,9 @@ them too (Rust uses Java's own float maths on each platform).
|
||||
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
|
||||
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
|
||||
|
||||
### Roadmap
|
||||
### Known issues
|
||||
|
||||
**Known differences.** Online-mode servers (Mojang's session servers) are not supported; skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
**Final checks.** A from-scratch audit (`tools/parity.py --all`) on Linux and on macOS, and the launch checks on the packaged Linux, macOS and Windows builds listed in [Rust rewrite status](docs/rust-parity.md#completion-criteria-and-playable-builds).
|
||||
|
||||
**Next.** With vanilla parity as the base:
|
||||
|
||||
- **Performance:** optimize the client and the server as far as they can go (rendering, chunk generation and meshing, memory, multithreaded ticking) while the differential harnesses keep gameplay identical to vanilla.
|
||||
- **Modding support:** a stable API for writing mods against Horizon.
|
||||
- **Agent control:** an MCP server and a CLI so AI agents and scripts can play and drive the game, growing out of today's `horizon-ctl` scripting socket.
|
||||
- **Built-in recording:** capture gameplay video in a keypress, without external tools.
|
||||
- **Automatic version updates:** a pipeline that ports each new Minecraft release on its own: compare the new version with the pinned one, regenerate the data, rewrite the changed parts and prove them with the same differential harnesses.
|
||||
Online-mode servers (Mojang's session servers) are not supported; skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ symphonia = { version = "0.5", default-features = false, features = ["ogg", "vor
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[features]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Transport for ClientLevel shape-aware terrain-particle placement.
|
||||
use horizon_client::{assets::Assets, blocks::BlockFacts, mesher::MeshTables, models, particles};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let root = Path::new(&args[1]);
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
let mut output = BufWriter::new(fs::File::create(&args[3])?);
|
||||
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
|
||||
let pos: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
|
||||
let offset = if facts.get(state).outline_offset {
|
||||
tables.offset(state, pos[0], pos[2]).unwrap_or([0.0; 3])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
let boxes: Vec<[f64; 6]> = facts
|
||||
.get(state)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| std::array::from_fn(|i| b[i] + offset[i % 3]))
|
||||
.collect();
|
||||
let mut hits = Vec::new();
|
||||
let mut next = Vec::new();
|
||||
for face in [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
] {
|
||||
let mut random = LegacyRandom::new(row["seed"].as_i64().unwrap());
|
||||
hits.push(particles::breaking_position(&mut random, pos, face, &boxes));
|
||||
next.push(random.next_long());
|
||||
}
|
||||
let mut destroy = Vec::new();
|
||||
particles::destroy_positions(pos, &boxes, |p, _| destroy.push(p));
|
||||
serde_json::to_writer(
|
||||
&mut output,
|
||||
&json!({"case":row["case"],"hits":hits,"next":next,"destroy":destroy}),
|
||||
)?;
|
||||
output.write_all(&[10])?;
|
||||
}
|
||||
output.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Headless transport for real model-shaped breaking overlays.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
breaking,
|
||||
mesher::MeshTables,
|
||||
models,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let root = Path::new(&args[1]);
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let tables = MeshTables::new(
|
||||
&assets,
|
||||
&facts,
|
||||
models::load_visibility(root, assets.registry)?,
|
||||
)?;
|
||||
let mut output = BufWriter::new(fs::File::create(&args[3])?);
|
||||
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
|
||||
let p: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
|
||||
let camera: [f64; 3] = serde_json::from_value(row["camera"].clone())?;
|
||||
let mut vertices = Vec::new();
|
||||
let mut translucent = false;
|
||||
if facts.get(state).render_shape == RenderShape::Model {
|
||||
let models = assets
|
||||
.model_catalog
|
||||
.collect(state, BlockPos::new(p[0], p[1], p[2]))?;
|
||||
let offset = tables.offset(state, p[0], p[2]).unwrap_or([0.0; 3]);
|
||||
let origin = (glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
|
||||
- glam::DVec3::from(camera))
|
||||
.as_vec3()
|
||||
+ glam::DVec3::from(offset).as_vec3();
|
||||
translucent = models
|
||||
.iter()
|
||||
.any(|(_, m)| m.faces.iter().any(|f| f.translucent));
|
||||
for (_, m) in models {
|
||||
breaking::geometry(m, origin, translucent, &mut vertices);
|
||||
}
|
||||
}
|
||||
let quads: Vec<_> = vertices
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| {
|
||||
q.iter()
|
||||
.map(|v| {
|
||||
assert_eq!(v.color, 0xffff_ffff);
|
||||
assert_eq!(v.light, 240 | (240 << 8));
|
||||
assert_eq!(v.layer, if translucent { 12 } else { 11 });
|
||||
[v.pos[0], v.pos[1], v.pos[2], v.uv[0], v.uv[1]]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_writer(
|
||||
&mut output,
|
||||
&json!({"case":row["case"],"translucent":translucent,"quads":quads}),
|
||||
)?;
|
||||
output.write_all(&[10])?;
|
||||
}
|
||||
output.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,6 +87,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! CloudRenderer face selection and mesh traversal, without a GPU.
|
||||
use horizon_client::clouds;
|
||||
use std::{fmt::Write, fs};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let mut output = String::new();
|
||||
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
|
||||
let values: Vec<i32> = line
|
||||
.split_whitespace()
|
||||
.map(|v| v.parse().unwrap())
|
||||
.collect();
|
||||
let [kind, x, z, bottom, neighbors] = values.as_slice() else {
|
||||
panic!("invalid case")
|
||||
};
|
||||
write!(output, "{case}")?;
|
||||
let mut cell = |x, z, neighbors| {
|
||||
let empty = std::array::from_fn(|i| neighbors & (1 << i) != 0);
|
||||
for (direction, inside) in clouds::faces(clouds::face_mask(x, z, *bottom as f32, empty))
|
||||
{
|
||||
write!(output, " {x},{z},{direction},{}", u8::from(inside)).unwrap();
|
||||
}
|
||||
};
|
||||
let radius = if *kind == 2 {
|
||||
clouds::radius_cells(*x)
|
||||
} else {
|
||||
*x
|
||||
};
|
||||
if *kind == 0 {
|
||||
cell(*x, *z, *neighbors);
|
||||
} else {
|
||||
for (x, z) in clouds::cells(radius) {
|
||||
cell(x, z, 0);
|
||||
}
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_container_screen_parity.py's screens and mouse events
|
||||
//! through `horizon_client::container_screen`.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
container_screen::{Book, Button, Clicks, Image, Release, Target, clicked_outside},
|
||||
menu::Input,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn input_name(input: Input) -> &'static str {
|
||||
match input {
|
||||
Input::Pickup => "PICKUP",
|
||||
Input::QuickMove => "QUICK_MOVE",
|
||||
Input::Swap => "SWAP",
|
||||
Input::Clone => "CLONE",
|
||||
Input::Throw => "THROW",
|
||||
Input::QuickCraft => "QUICK_CRAFT",
|
||||
Input::PickupAll => "PICKUP_ALL",
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let int = |v: &Value| v.as_i64().unwrap() as i32;
|
||||
let image = Image {
|
||||
left: int(&c["image"][0]),
|
||||
top: int(&c["image"][1]),
|
||||
width: int(&c["image"][2]),
|
||||
height: int(&c["image"][3]),
|
||||
};
|
||||
let book = c.get("book").map(|b| Book {
|
||||
visible: b[0].as_bool().unwrap(),
|
||||
selected_tab_hovered: b[1].as_bool().unwrap(),
|
||||
});
|
||||
let mut clicks = Clicks::default();
|
||||
for ev in c["events"].as_array().unwrap() {
|
||||
let name = ev[0].as_str().unwrap();
|
||||
let (x, y) = (ev[1].as_f64().unwrap(), ev[2].as_f64().unwrap());
|
||||
let outside = clicked_outside(x, y, image, book);
|
||||
let mut line = format!("{name} {outside}");
|
||||
if name != "point" {
|
||||
// The case says whether the event is over the menu's slot (7).
|
||||
let on_slot = ev[6].as_bool().unwrap();
|
||||
line += &format!(" {on_slot}");
|
||||
let target = Target::of(on_slot.then_some(7), outside);
|
||||
let button = match ev[3].as_i64().unwrap() {
|
||||
1 => Button::Left,
|
||||
3 => Button::Right,
|
||||
_ => Button::Middle,
|
||||
};
|
||||
let shift = ev[4].as_i64().unwrap() & 3 != 0;
|
||||
let carried_empty = !ev[5].as_bool().unwrap();
|
||||
let click = if name == "press" {
|
||||
clicks.press(&target, button, carried_empty, shift, false)
|
||||
} else {
|
||||
match clicks.release(&target, button, carried_empty, false, shift, false) {
|
||||
Release::Click(input) => Some(input),
|
||||
Release::QuickCraft => panic!("no drags in these cases"),
|
||||
Release::Nothing => None,
|
||||
}
|
||||
};
|
||||
if let Some(input) = click {
|
||||
let id = match target {
|
||||
Target::Slot(s) => s,
|
||||
Target::Outside => -999,
|
||||
Target::Nothing => -1,
|
||||
};
|
||||
line += &format!(" click {id} {} {}", button.menu_button(), input_name(input));
|
||||
}
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! The creative inventory, for tools/test_creative_parity.py: loads an
|
||||
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
|
||||
//! and prints the tabs it shows (canonical stacks) and the search tab's
|
||||
//! results for every query of the vanilla oracle's output.
|
||||
//!
|
||||
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
|
||||
use horizon_client::{
|
||||
creative::{Creative, Names},
|
||||
item_data::canon_stack,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
|
||||
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
|
||||
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
|
||||
args.get(2).ok_or(usage)?,
|
||||
)?)?)?;
|
||||
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
|
||||
let tabs: Vec<Value> = creative
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|t| {
|
||||
json!({
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
"icon": t.icon,
|
||||
"row": if t.top { "TOP" } else { "BOTTOM" },
|
||||
"column": t.column,
|
||||
"items": t.items.iter().map(|s| canon_stack(Some(s))).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let searches: Vec<Value> = vanilla["searches"]
|
||||
.as_array()
|
||||
.ok_or("searches")?
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let query = s["query"].as_str().unwrap_or_default();
|
||||
json!({"query": query, "results": creative.search_results(query, &names)})
|
||||
})
|
||||
.collect();
|
||||
let search: Vec<String> = creative
|
||||
.search_items()
|
||||
.iter()
|
||||
.map(|s| canon_stack(Some(s)))
|
||||
.collect();
|
||||
let out = json!({"tabs": tabs, "search": search, "searches": searches});
|
||||
fs::write(args.get(4).ok_or(usage)?, out.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_entity_components_parity.py's entities and advancement
|
||||
//! entity predicates through `horizon_client::advancements::entity`, with the
|
||||
//! variant component `mobs::finalize::species_variant_component` reports.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
advancements::{EntityView, World, entity},
|
||||
mobs::{
|
||||
Species,
|
||||
finalize::{species_variant_component, variant_index},
|
||||
},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
/// Nothing but the entity: these predicates read only its type and components.
|
||||
struct Empty;
|
||||
impl World for Empty {
|
||||
fn dimension(&self) -> &str {
|
||||
"minecraft:overworld"
|
||||
}
|
||||
fn biome(&self, _: [i32; 3]) -> String {
|
||||
"minecraft:plains".into()
|
||||
}
|
||||
fn structures(&self, _: [i32; 3]) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
|
||||
("minecraft:air".into(), BTreeMap::new())
|
||||
}
|
||||
fn fluid(&self, _: [i32; 3]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn can_see_sky(&self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
}
|
||||
fn light(&self, _: [i32; 3]) -> i32 {
|
||||
15
|
||||
}
|
||||
fn smokey(&self, _: [i32; 3]) -> bool {
|
||||
false
|
||||
}
|
||||
fn in_tag(&self, _: &str, _: &str, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn species(id: &str) -> Species {
|
||||
match id {
|
||||
"minecraft:wolf" => Species::Wolf,
|
||||
"minecraft:cat" => Species::Cat,
|
||||
"minecraft:frog" => Species::Frog,
|
||||
"minecraft:pig" => Species::Pig,
|
||||
"minecraft:cow" => Species::Cow,
|
||||
"minecraft:chicken" => Species::Chicken,
|
||||
other => panic!("no species for {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let kind = c["type"].as_str().unwrap();
|
||||
let s = species(kind);
|
||||
let variant = variant_index(s, c["variant"].as_str().unwrap());
|
||||
let view = EntityView {
|
||||
kind: kind.to_owned(),
|
||||
position: [0.5, 64.0, 0.5],
|
||||
components: species_variant_component(s, variant)
|
||||
.map(|(id, v)| (id.to_owned(), v))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for p in c["predicates"].as_array().unwrap() {
|
||||
writeln!(out, "{}", entity(p, &view, &Empty, [2.5, 64.0, 0.5]))?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -64,6 +64,10 @@ fn main() -> horizon_client::Result<()> {
|
||||
);
|
||||
put("visual/fog_start_distance", format!("{:?}", sky.fog_start));
|
||||
put("visual/fog_end_distance", format!("{:?}", sky.fog_end));
|
||||
put(
|
||||
"visual/cloud_fog_end_distance",
|
||||
format!("{:?}", sky.cloud_fog_end),
|
||||
);
|
||||
out.push_str(&row);
|
||||
out.push_str("}\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_fire_starter_sound_parity.py's level random seeds through
|
||||
//! the fire charge's use sound (`horizon_client::items::fire_starter_pitch`).
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::{fire_starter_pitch, fire_starter_sound},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in input.lines() {
|
||||
let mut random = LegacyRandom::new(line?.trim().parse()?);
|
||||
let pitch = fire_starter_pitch(true, &mut random);
|
||||
writeln!(
|
||||
out,
|
||||
"{} {} {} {}",
|
||||
fire_starter_sound(true),
|
||||
1.0f32.to_bits() as i32,
|
||||
pitch.to_bits() as i32,
|
||||
random.next_long()
|
||||
)?;
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use horizon_client::{
|
||||
Result,
|
||||
attack_strength::{AttackStrength, HeldStack},
|
||||
first_person::{
|
||||
Clicks, Hand, HandHeights, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
|
||||
Clicks, Hand, HandHeights, HandSway, HandWorld, Hit, SwingAnimation, SwingState, UseResult,
|
||||
swing_duration,
|
||||
},
|
||||
};
|
||||
@@ -308,6 +308,9 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let mut strength = AttackStrength::default();
|
||||
// FirstPersonHandsAndItems' visible items start as ItemStack.EMPTY.
|
||||
let mut visible = [0usize, 0usize];
|
||||
// The player's xRot and yRot, and the hands' lag behind them.
|
||||
let mut rot = [0.0f32; 2];
|
||||
let mut sway = HandSway::default();
|
||||
for (s, step) in case["steps"].as_array().unwrap().iter().enumerate() {
|
||||
probe.step = step;
|
||||
probe.events.clear();
|
||||
@@ -327,6 +330,10 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
|
||||
step["ud"].as_bool().unwrap(),
|
||||
);
|
||||
probe.swing.tick();
|
||||
if let Some(r) = step["rot"].as_array() {
|
||||
rot = [fl(&r[0]), fl(&r[1])];
|
||||
}
|
||||
sway.tick(rot[0], rot[1]);
|
||||
let main = probe.stack(Hand::Main).clone();
|
||||
strength.tick(held(&main));
|
||||
// FirstPersonHandsAndItems.tick: shouldInstantlyReplaceVisibleItem
|
||||
@@ -363,7 +370,7 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let d = &clicks.destroying;
|
||||
writeln!(
|
||||
out,
|
||||
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
"tick {s} {current} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
sw.ticks,
|
||||
fb(sw.animation),
|
||||
fb(sw.old_animation),
|
||||
@@ -379,7 +386,11 @@ fn run(case: &Value, out: &mut impl Write) -> Result<()> {
|
||||
clicks.right_click_delay,
|
||||
d.delay,
|
||||
d.active,
|
||||
fb(d.progress)
|
||||
fb(d.progress),
|
||||
fb(sway.x_bob),
|
||||
fb(sway.y_bob),
|
||||
fb(sway.x_bob_o),
|
||||
fb(sway.y_bob_o)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Menu clicks for tools/test_menu_click_parity.py: reads one JSON case per
|
||||
//! line (menu, creative, slots, carried, data, ops) and prints the oracle's
|
||||
//! state lines after every click.
|
||||
//! state lines after every click. The `*_be` furnace menus run over a furnace
|
||||
//! block entity: its data slots follow `Furnace::input_clicked`.
|
||||
//!
|
||||
//! usage: menu_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
containers::{Furnace, FurnaceKind},
|
||||
crafting::Recipes,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
@@ -41,11 +43,25 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let kind = Kind::parse(case["menu"].as_str().unwrap_or_default()).ok_or("menu kind")?;
|
||||
let name = case["menu"].as_str().unwrap_or_default();
|
||||
// FurnaceMenu over the server's AbstractFurnaceBlockEntity.
|
||||
let furnace_kind = match name {
|
||||
"furnace_be" => Some(FurnaceKind::Furnace),
|
||||
"blast_furnace_be" => Some(FurnaceKind::BlastFurnace),
|
||||
"smoker_be" => Some(FurnaceKind::Smoker),
|
||||
_ => None,
|
||||
};
|
||||
let kind = Kind::parse(
|
||||
name.strip_suffix("_be")
|
||||
.filter(|_| furnace_kind.is_some())
|
||||
.unwrap_or(name),
|
||||
)
|
||||
.ok_or("menu kind")?;
|
||||
let mut menu = Menu::new(kind);
|
||||
menu.creative = case["creative"].as_bool().unwrap_or(false);
|
||||
for (i, slot) in case["slots"].as_array().ok_or("slots")?.iter().enumerate() {
|
||||
@@ -56,6 +72,15 @@ fn main() -> horizon_client::Result<()> {
|
||||
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
|
||||
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
|
||||
}
|
||||
let mut furnace = furnace_kind.map(|kind| Furnace {
|
||||
kind,
|
||||
items: [0, 1, 2].map(|i| menu.slot(i).cloned()),
|
||||
lit_remaining: menu.data[0],
|
||||
lit_total: menu.data[1],
|
||||
cooking_timer: menu.data[2],
|
||||
cooking_total: menu.data[3],
|
||||
speed: 1.0,
|
||||
});
|
||||
menu.drops.clear();
|
||||
out += &format!("init {}\n", state(&menu));
|
||||
for op in case["ops"].as_array().ok_or("ops")? {
|
||||
@@ -79,7 +104,15 @@ fn main() -> horizon_client::Result<()> {
|
||||
_ => {}
|
||||
}
|
||||
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
|
||||
match menu.click(&env, slot, button, kind) {
|
||||
let clicked = menu.click(&env, slot, button, kind);
|
||||
if let Some(f) = furnace.as_mut() {
|
||||
let before = f.items[0].clone();
|
||||
f.items = [0, 1, 2].map(|i| menu.slot(i).cloned());
|
||||
f.input_clicked(&recipes, before);
|
||||
menu.data[2] = f.cooking_timer;
|
||||
menu.data[3] = f.cooking_total;
|
||||
}
|
||||
match clicked {
|
||||
Ok(()) => out += &format!("{}\n", state(&menu)),
|
||||
Err(name) => out += &format!("error {name}\n"),
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ struct Scenario {
|
||||
spawning: bool,
|
||||
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
|
||||
hurts: Vec<(i32, usize)>,
|
||||
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
|
||||
/// (tick, mob index); the scenario only feeds each animal its food.
|
||||
feeds: Vec<(i32, usize)>,
|
||||
hand: Option<String>,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
@@ -92,6 +95,7 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
}
|
||||
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
@@ -893,6 +897,9 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
// The level's random for eating sounds: only goats and sniffers draw
|
||||
// from it, and the scenarios feed neither.
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, 0, i, m, s.creative);
|
||||
}
|
||||
@@ -996,6 +1003,16 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
events.append(&mut mobs[i].outbox);
|
||||
}
|
||||
for &(tick, i) in &s.feeds {
|
||||
if tick != t || i >= mobs.len() || mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((name, volume, pitch))) =
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
|
||||
{
|
||||
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
|
||||
}
|
||||
}
|
||||
#[allow(unused_mut)] // filled only with the rng-trace feature
|
||||
let mut traces: Vec<String> = Vec::new();
|
||||
let mut pushes = Vec::new();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The resource root Horizon builds for a pack selection (`packs::build_overlay`)
|
||||
//! and what its loaders read from it, for VanillaPackResourcesOracle.
|
||||
use horizon_client::{lang::Language, packs, sound_events::SoundRegistry};
|
||||
use std::{fmt::Write, fs, path::PathBuf};
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| {
|
||||
(h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3)
|
||||
})
|
||||
}
|
||||
/// `namespace:path` to a file under the root's `assets/`.
|
||||
fn asset(root: &std::path::Path, id: &str, prefix: &str, suffix: &str) -> PathBuf {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
root.join("assets")
|
||||
.join(namespace)
|
||||
.join(format!("{prefix}{path}{suffix}"))
|
||||
}
|
||||
fn types(root: &std::path::Path, file: PathBuf, key: &str) -> String {
|
||||
let _ = root;
|
||||
let value: serde_json::Value = fs::read(file)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
value[key]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|v| {
|
||||
let t = v["type"].as_str().unwrap_or("?");
|
||||
format!(" {}", t.strip_prefix("minecraft:").unwrap_or(t))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let input = fs::read_to_string(&args[1])?;
|
||||
let mut lines = input.lines();
|
||||
let root: Vec<&str> = lines.next().ok_or("empty input")?.split(' ').collect();
|
||||
let vanilla = PathBuf::from(root[1]);
|
||||
let ids: Vec<String> = root[3..].iter().map(|s| (*s).to_owned()).collect();
|
||||
let all = packs::discover(&vanilla, &PathBuf::from(root[2]));
|
||||
let enabled = packs::enabled(&all, &ids);
|
||||
let overlay = PathBuf::from(&args[2]).with_extension("overlay");
|
||||
packs::build_overlay(&vanilla, &enabled, &overlay)?;
|
||||
let language = Language::load(&[&overlay], "en_us");
|
||||
let sounds = SoundRegistry::load(&overlay);
|
||||
let mut out = String::new();
|
||||
for line in lines {
|
||||
let (kind, arg) = line.split_once(' ').unwrap_or((line, ""));
|
||||
write!(out, "{line} =>")?;
|
||||
match kind {
|
||||
"file" => match fs::read(asset(&overlay, arg, "", "")) {
|
||||
Ok(bytes) => write!(out, " {:x}", fnv(&bytes))?,
|
||||
Err(_) => out.push_str(" missing"),
|
||||
},
|
||||
"lang" => write!(out, " {}", language.get(arg).unwrap_or(arg))?,
|
||||
"sound" => {
|
||||
let event = if arg.contains(':') {
|
||||
arg.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{arg}")
|
||||
};
|
||||
match sounds.get(&event) {
|
||||
None => out.push_str(" missing"),
|
||||
Some(e) => {
|
||||
write!(out, " subtitle={}", e.subtitle.as_deref().unwrap_or("none"))?;
|
||||
for s in &e.sounds {
|
||||
if s.event {
|
||||
out.push_str(" event");
|
||||
} else {
|
||||
write!(out, " {}*{}", s.location, s.weight)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"font" => out.push_str(&types(
|
||||
&overlay,
|
||||
asset(&overlay, arg, "font/", ".json"),
|
||||
"providers",
|
||||
)),
|
||||
"atlas" => out.push_str(&types(
|
||||
&overlay,
|
||||
asset(&overlay, arg, "atlases/", ".json"),
|
||||
"sources",
|
||||
)),
|
||||
_ => return Err(format!("unknown query {line}").into()),
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_particle_providers_parity.py's particles through
|
||||
//! `ParticleEngine`: built with the seeds the vanilla oracle recovered, then
|
||||
//! ticked over air or a stone floor at y 63, printing their state each tick.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
chunk::Chunk,
|
||||
collisions,
|
||||
particles::{ParticleEngine, ParticleLevel, ParticleOptions, ParticleSnapshot, ParticleType},
|
||||
world::World,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
fn f(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
fn color(v: &Value, default: u32) -> u32 {
|
||||
v.as_u64().map_or(default, |c| c as u32)
|
||||
}
|
||||
fn rgb(c: u32) -> [f32; 3] {
|
||||
[
|
||||
((c >> 16) & 255) as f32 / 255.0,
|
||||
((c >> 8) & 255) as f32 / 255.0,
|
||||
(c & 255) as f32 / 255.0,
|
||||
]
|
||||
}
|
||||
fn options(case: &Value) -> Option<ParticleOptions> {
|
||||
let ty = ParticleType::from_name(case["type"].as_str()?)?;
|
||||
let o = &case["options"];
|
||||
let scale = o["scale"].as_f64().unwrap_or(1.0) as f32;
|
||||
let c = color(&o["color"], 0xFFFF_FFFF);
|
||||
let argb = {
|
||||
let [r, g, b] = rgb(c);
|
||||
[r, g, b, ((c >> 24) & 255) as f32 / 255.0]
|
||||
};
|
||||
Some(match ty {
|
||||
ParticleType::Block
|
||||
| ParticleType::BlockCrumble
|
||||
| ParticleType::DustPillar
|
||||
| ParticleType::BlockMarker => ParticleOptions::block(ty, "terrain".into(), 0xFF_FFFF),
|
||||
// Stone's map colour: FallingDustParticle.Provider's tint without a tint source.
|
||||
ParticleType::FallingDust => ParticleOptions::block(ty, "terrain".into(), 0x70_7070),
|
||||
ParticleType::Dust => ParticleOptions::dust(rgb(c), scale),
|
||||
ParticleType::DustColorTransition => {
|
||||
ParticleOptions::dust_transition(rgb(c), rgb(color(&o["to"], 0)), scale)
|
||||
}
|
||||
ParticleType::Effect | ParticleType::InstantEffect => ParticleOptions {
|
||||
scale,
|
||||
..ParticleOptions::colored(ty, argb)
|
||||
},
|
||||
// The oracle's item models: one layer whose sprite is named after the item.
|
||||
ParticleType::Item => {
|
||||
let item = o["item"].as_str()?;
|
||||
ParticleOptions::item(vec![format!(
|
||||
"item_{}",
|
||||
item.strip_prefix("minecraft:").unwrap_or(item)
|
||||
)])
|
||||
}
|
||||
ParticleType::EntityEffect | ParticleType::TintedLeaves | ParticleType::Flash => {
|
||||
ParticleOptions::colored(ty, argb)
|
||||
}
|
||||
ParticleType::DragonBreath => ParticleOptions {
|
||||
scale,
|
||||
..ParticleOptions::simple(ty)
|
||||
},
|
||||
ParticleType::SculkCharge => ParticleOptions::sculk_charge(scale),
|
||||
ParticleType::Shriek => ParticleOptions::shriek(scale as i32),
|
||||
ParticleType::Trail => {
|
||||
let t = &o["target"];
|
||||
ParticleOptions::trail(
|
||||
[t[0].as_f64()?, t[1].as_f64()?, t[2].as_f64()?],
|
||||
c & 0xFF_FFFF,
|
||||
o["duration"].as_i64()? as i32,
|
||||
)
|
||||
}
|
||||
_ => ParticleOptions::simple(ty),
|
||||
})
|
||||
}
|
||||
fn state(s: &ParticleSnapshot) -> String {
|
||||
let mut out = format!(
|
||||
"{} {} {} {}",
|
||||
s.age,
|
||||
s.lifetime,
|
||||
u8::from(s.removed),
|
||||
u8::from(s.on_ground)
|
||||
);
|
||||
for v in s.position.iter().chain(&s.velocity).chain(&s.previous) {
|
||||
out += &format!(" {}", d(*v));
|
||||
}
|
||||
if let Some(q) = &s.quad {
|
||||
out += &format!(" | {} {}", f(q.size[0]), f(q.size[1]));
|
||||
for v in q.rgba.iter().chain(&q.roll) {
|
||||
out += &format!(" {}", f(*v));
|
||||
}
|
||||
out += &format!(" {}", q.sprite);
|
||||
for v in q.uv {
|
||||
out += &format!(" {}", f(v));
|
||||
}
|
||||
out += &format!(" {:x}", q.light);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn world(assets: &Assets, facts: &BlockFacts, floor: bool) -> Result<World> {
|
||||
let mut world = World::new(facts.air, 0);
|
||||
let stone = assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.ok_or("missing stone")?;
|
||||
for cz in -3..=2 {
|
||||
for cx in -3..=2 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
if floor {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
world.set(cx * 16 + x, 63, cz * 16 + z, stone)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(".local/horizon-client/26.3");
|
||||
let assets = Assets::load(root)?;
|
||||
let facts = BlockFacts::load(root, assets.registry)?;
|
||||
let collision = collisions::load(root, assets.registry)?;
|
||||
let worlds = [
|
||||
world(&assets, &facts, false)?,
|
||||
world(&assets, &facts, true)?,
|
||||
];
|
||||
let mut engine = ParticleEngine::new(root, &HashMap::new());
|
||||
for (ty, item) in [
|
||||
(ParticleType::ItemSlime, "item_slime_ball"),
|
||||
(ParticleType::ItemSnowball, "item_snowball"),
|
||||
(ParticleType::ItemCobweb, "item_cobweb"),
|
||||
] {
|
||||
engine.item_sprites.insert(ty, vec![item.to_owned()]);
|
||||
}
|
||||
// The oracle's output: the seed line of each case.
|
||||
let mut seeds = Vec::new();
|
||||
for line in BufReader::new(File::open(&args[2])?).lines() {
|
||||
let line = line?;
|
||||
if line.starts_with("seed ") || line.starts_with("null ") || line == "no provider" {
|
||||
seeds.push(line);
|
||||
}
|
||||
}
|
||||
let mut out = BufWriter::new(File::create(&args[3])?);
|
||||
for (n, line) in BufReader::new(File::open(&args[1])?).lines().enumerate() {
|
||||
let case: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let Some(options) = options(&case) else {
|
||||
writeln!(out, "no provider")?;
|
||||
continue;
|
||||
};
|
||||
let seed = case["seed"].as_i64().ok_or("seed")?;
|
||||
engine.seed_randoms(seed, seed ^ 0x6C_6576_656C, seed ^ 0x73_7065_6C6C);
|
||||
engine.requested = Some(Vec::new());
|
||||
engine.sounds.clear();
|
||||
let level = ParticleLevel {
|
||||
world: &worlds[usize::from(case["floor"].as_bool() == Some(true))],
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
};
|
||||
let v = |key: &str| -> [f64; 3] {
|
||||
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
|
||||
};
|
||||
let seed_line = seeds.get(n).cloned().unwrap_or_default();
|
||||
let local = seed_line
|
||||
.split(' ')
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
let Some(mut particle) = engine.probe_create(&options, v("pos"), v("vel"), local, &level)
|
||||
else {
|
||||
writeln!(out, "null {}", engine.next_randoms()[0])?;
|
||||
continue;
|
||||
};
|
||||
writeln!(out, "{seed_line}")?;
|
||||
let [a, b, c] = engine.next_randoms();
|
||||
writeln!(out, "next {a} {b} {c}")?;
|
||||
let events = |engine: &mut ParticleEngine, out: &mut BufWriter<File>| -> Result<()> {
|
||||
for (o, p, v) in engine.requested.replace(Vec::new()).unwrap_or_default() {
|
||||
write!(out, "+ {}", o.ty.name())?;
|
||||
for x in p.iter().chain(&v) {
|
||||
write!(out, " {}", d(*x))?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
}
|
||||
for (name, p, volume, pitch) in std::mem::take(&mut engine.sounds) {
|
||||
writeln!(
|
||||
out,
|
||||
"~ {name} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
f(volume),
|
||||
f(pitch)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
writeln!(out, "0 {}", state(&engine.snapshot(&particle)))?;
|
||||
events(&mut engine, &mut out)?;
|
||||
let ticks = case["ticks"].as_i64().unwrap_or(0);
|
||||
let mut t = 1;
|
||||
while t <= ticks && particle.is_alive() {
|
||||
engine.probe_tick(&mut particle, &level);
|
||||
writeln!(out, "{t} {}", state(&engine.snapshot(&particle)))?;
|
||||
events(&mut engine, &mut out)?;
|
||||
t += 1;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -43,6 +43,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (line, order) in cases.lines().zip(orders.lines()) {
|
||||
|
||||
@@ -40,6 +40,7 @@ fn main() -> Result<()> {
|
||||
let mut scene = collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
|
||||
@@ -165,6 +165,17 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::Fall(e) => Some(e.trace()),
|
||||
MotionEvent::Health(e) => Some(e.trace()),
|
||||
MotionEvent::Snowflake { position, velocity } => Some(format!(
|
||||
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
|
||||
position.x.to_bits(),
|
||||
position.y.to_bits(),
|
||||
position.z.to_bits(),
|
||||
velocity.x.to_bits(),
|
||||
velocity.y.to_bits(),
|
||||
velocity.z.to_bits()
|
||||
)),
|
||||
// The oracle marks every getAdvancements call (ENTER_BLOCK.trigger) so.
|
||||
MotionEvent::EnterBlock { .. } => Some("hurt-criterion".into()),
|
||||
MotionEvent::Waypoint { receiver, enabled } => Some(format!(
|
||||
"waypoint {} {enabled}",
|
||||
if *receiver { "receive" } else { "transmit" }
|
||||
@@ -232,6 +243,8 @@ fn main() -> Result<()> {
|
||||
instances.next().expect("unexpected instance allocation")
|
||||
}),
|
||||
)?;
|
||||
// The oracle runs block-inside effects only in "snow_tick".
|
||||
player.block_effects = false;
|
||||
for op in f["ops"].as_array().unwrap() {
|
||||
let mut world = MotionWorld {
|
||||
player: &mut player,
|
||||
@@ -409,6 +422,26 @@ fn main() -> Result<()> {
|
||||
op["jumping"].as_bool().unwrap(),
|
||||
)?;
|
||||
}
|
||||
"snow_tick" => {
|
||||
// The clock, then the player tick: baseTick's powder snow
|
||||
// flags lead PlayerMotion::player_movement_tick.
|
||||
world.player.health.advance_clock();
|
||||
let v = vector(&op["value"]);
|
||||
world.player.block_effects = true;
|
||||
let ticked = world.player_movement_tick(
|
||||
[v.x as f32, v.z as f32],
|
||||
op["jumping"].as_bool().unwrap(),
|
||||
);
|
||||
world.player.block_effects = false;
|
||||
ticked?;
|
||||
}
|
||||
"boots" => {
|
||||
// Leather boots are also freeze-immune wearables.
|
||||
let boots = op["value"].as_bool().unwrap();
|
||||
world.player.leather_boots = boots;
|
||||
world.player.freeze_immune = boots;
|
||||
}
|
||||
"frozen" => world.player.ticks_frozen = op["value"].as_i64().unwrap() as i32,
|
||||
"trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32,
|
||||
"delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32,
|
||||
"move" => world.move_self(vector(&op["value"]))?,
|
||||
@@ -478,7 +511,18 @@ fn main() -> Result<()> {
|
||||
}
|
||||
let check_block = scene.block(25, 3, 25);
|
||||
snapshot(&mut out, &player)?;
|
||||
writeln!(out, "block {}", check_block.0)?;
|
||||
let stuck = player.stuck_multiplier;
|
||||
writeln!(
|
||||
out,
|
||||
"block {} frozen {} {} {} {:016x},{:016x},{:016x}",
|
||||
check_block.0,
|
||||
player.ticks_frozen,
|
||||
player.in_powder_snow,
|
||||
player.was_in_powder_snow,
|
||||
stuck.x.to_bits(),
|
||||
stuck.y.to_bits(),
|
||||
stuck.z.to_bits()
|
||||
)?;
|
||||
let h = &player.health;
|
||||
let canonical = |v: f32| {
|
||||
if v.is_nan() {
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
//! Trading for tools/test_trading_parity.py: runs each case of INPUT as
|
||||
//! VanillaTradingOracle does (offer generation, special prices, restocks,
|
||||
//! the merchant menu, NBT) and prints the same lines.
|
||||
//!
|
||||
//! usage: trading_fixture RESOURCES INPUT VANILLA_OUTPUT OUTPUT
|
||||
use horizon_client::{
|
||||
enchant::Enchantments,
|
||||
entity_nbt,
|
||||
gossip::{GossipType, Gossips},
|
||||
items::{ItemStack, Items},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
merchant::{self, Event, MerchantOffer, Session, TradeContext, TradeEnv, Trader, Trades},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{collections::HashMap, fmt::Write as _, fs, path::Path};
|
||||
|
||||
const PLAYER: [i32; 4] = [0, 0x1234, 0, 0x5678];
|
||||
|
||||
/// Canonical NBT text, as VanillaTradingOracle prints it.
|
||||
fn canon(t: &Tag, out: &mut String) {
|
||||
match t {
|
||||
Tag::Compound(c) => {
|
||||
out.push('{');
|
||||
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
|
||||
keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
quote(k, out);
|
||||
out.push(':');
|
||||
canon(t.get(k).expect("key"), out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Tag::List(l) => {
|
||||
out.push('[');
|
||||
for (i, v) in l.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canon(v, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Tag::String(s) => quote(&s.to_string_lossy(), out),
|
||||
Tag::Byte(v) => write!(out, "{v}b").unwrap(),
|
||||
Tag::Short(v) => write!(out, "{v}s").unwrap(),
|
||||
Tag::Int(v) => write!(out, "{v}").unwrap(),
|
||||
Tag::Long(v) => write!(out, "{v}L").unwrap(),
|
||||
Tag::Float(v) => write!(out, "f:{v:x}").unwrap(),
|
||||
Tag::Double(v) => write!(out, "d:{v:x}").unwrap(),
|
||||
Tag::ByteArray(v) => {
|
||||
out.push_str("[B;");
|
||||
let s: Vec<String> = v.iter().map(|b| (*b as i8).to_string()).collect();
|
||||
out.push_str(&s.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::IntArray(v) => {
|
||||
out.push_str("[I;");
|
||||
let s: Vec<String> = v.iter().map(i32::to_string).collect();
|
||||
out.push_str(&s.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::LongArray(v) => {
|
||||
out.push_str("[L;");
|
||||
let s: Vec<String> = v.iter().map(i64::to_string).collect();
|
||||
out.push_str(&s.join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::End => out.push_str("?End"),
|
||||
}
|
||||
}
|
||||
fn quote(s: &str, out: &mut String) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
if c == '"' || c == '\\' {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
fn canon_str(t: &Tag) -> String {
|
||||
let mut s = String::new();
|
||||
canon(t, &mut s);
|
||||
s
|
||||
}
|
||||
fn stack(s: Option<&ItemStack>) -> String {
|
||||
match s {
|
||||
None => "-".into(),
|
||||
Some(s) => canon_str(&horizon_client::save::item_to_nbt(s)),
|
||||
}
|
||||
}
|
||||
|
||||
struct World<'a> {
|
||||
items: &'a Items,
|
||||
enchantments: &'a Enchantments,
|
||||
trades: &'a Trades,
|
||||
sequences: RandomSequences,
|
||||
seed: i64,
|
||||
}
|
||||
/// A merchant from PROF:TYPE:LEVEL or "wt", generated level by level.
|
||||
struct Merchant {
|
||||
villager: bool,
|
||||
profession: String,
|
||||
variant: String,
|
||||
level: i32,
|
||||
trader: Trader,
|
||||
}
|
||||
impl World<'_> {
|
||||
fn reset(&mut self, seed: i64) {
|
||||
self.sequences = RandomSequences::default();
|
||||
self.seed = seed;
|
||||
}
|
||||
fn add_set(
|
||||
&mut self,
|
||||
offers: &mut Vec<MerchantOffer>,
|
||||
set: &str,
|
||||
variant: &str,
|
||||
origin: [f64; 3],
|
||||
) {
|
||||
let Some(sequence) = self.trades.sequence(set) else {
|
||||
return;
|
||||
};
|
||||
let ctx = TradeContext {
|
||||
items: self.items,
|
||||
enchantments: self.enchantments,
|
||||
variant,
|
||||
origin,
|
||||
maps: None,
|
||||
};
|
||||
let random = self.sequences.get(&sequence, self.seed);
|
||||
offers.extend(self.trades.offers_from_set(&ctx, set, random));
|
||||
}
|
||||
fn merchant(&mut self, spec: &str, origin: [f64; 3]) -> Merchant {
|
||||
let mut offers = Vec::new();
|
||||
if spec == "wt" {
|
||||
for set in ["buying", "uncommon", "common"] {
|
||||
self.add_set(
|
||||
&mut offers,
|
||||
&format!("minecraft:wandering_trader/{set}"),
|
||||
"minecraft:plains",
|
||||
origin,
|
||||
);
|
||||
}
|
||||
return Merchant {
|
||||
villager: false,
|
||||
profession: "minecraft:wandering_trader".into(),
|
||||
variant: "minecraft:plains".into(),
|
||||
level: 1,
|
||||
trader: Trader {
|
||||
offers,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
}
|
||||
let p: Vec<&str> = spec.split(':').collect();
|
||||
let (profession, variant) = (format!("minecraft:{}", p[0]), format!("minecraft:{}", p[1]));
|
||||
let max: i32 = p[2].parse().unwrap_or(1);
|
||||
for level in 1..=max {
|
||||
let set = Trades::set_for(&profession, level);
|
||||
if self.trades.sets.contains_key(&set) {
|
||||
self.add_set(&mut offers, &set, &variant, origin);
|
||||
}
|
||||
}
|
||||
Merchant {
|
||||
villager: true,
|
||||
profession,
|
||||
variant,
|
||||
level: max,
|
||||
trader: Trader {
|
||||
offers,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kv(fields: &[&str]) -> HashMap<String, String> {
|
||||
let mut m: HashMap<String, String> = HashMap::new();
|
||||
for f in &fields[2..] {
|
||||
if let Some((k, v)) = f.split_once('=') {
|
||||
m.entry(k.to_owned())
|
||||
.and_modify(|e| {
|
||||
e.push(' ');
|
||||
e.push_str(v);
|
||||
})
|
||||
.or_insert_with(|| v.to_owned());
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Uses, demand, gossip, xp and restock state, as the oracle's `prepare`.
|
||||
fn prepare(m: &mut Merchant, gossips: &mut Gossips, kv: &HashMap<String, String>) {
|
||||
for key in ["uses", "demand"] {
|
||||
let Some(list) = kv.get(key) else { continue };
|
||||
for s in list.split(' ') {
|
||||
let mut f = s.split(':');
|
||||
let i: usize = f.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let n: i32 = f.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let Some(o) = m.trader.offers.get_mut(i) else {
|
||||
continue;
|
||||
};
|
||||
if key == "uses" {
|
||||
o.uses += n;
|
||||
} else {
|
||||
o.demand = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(list) = kv.get("gossip") {
|
||||
for s in list.split(' ') {
|
||||
let (kind, n) = s.split_once(':').unwrap_or((s, "0"));
|
||||
if let Some(kind) = GossipType::from_id(kind) {
|
||||
gossips.add(PLAYER, kind, n.parse().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(xp) = kv.get("xp") {
|
||||
m.trader.xp = xp.parse().unwrap_or(0);
|
||||
}
|
||||
if let Some(v) = kv.get("lastrestock") {
|
||||
m.trader.last_restock = v.parse().unwrap_or(0);
|
||||
}
|
||||
if let Some(v) = kv.get("restocks") {
|
||||
m.trader.restocks_today = v.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: trading_fixture RESOURCES INPUT VANILLA_OUTPUT OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let trades = Trades::load(root);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, std::sync::Arc::new(v)))
|
||||
.collect();
|
||||
// The vanilla villagers the nbt cases read.
|
||||
let snbt: HashMap<&str, &str> = vanilla
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let (name, rest) = l.split_once(' ')?;
|
||||
Some((name, rest.strip_prefix("snbt ")?))
|
||||
})
|
||||
.collect();
|
||||
let mut world = World {
|
||||
items: &items,
|
||||
enchantments: &enchantments,
|
||||
trades: &trades,
|
||||
sequences: RandomSequences::default(),
|
||||
seed: 0,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let fields: Vec<&str> = line.split(' ').collect();
|
||||
let (kind, name) = (fields[0], fields[1]);
|
||||
let kv = kv(&fields);
|
||||
let seed: i64 = kv.get("seed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
world.reset(seed);
|
||||
let origin = [0.5, -60.0, 0.5];
|
||||
match kind {
|
||||
"gen" => {
|
||||
let specs: Vec<String> = kv["v"].split(' ').map(str::to_owned).collect();
|
||||
for (k, spec) in specs.iter().enumerate() {
|
||||
let m = world.merchant(spec, origin);
|
||||
writeln!(
|
||||
out,
|
||||
"{name} v{k} {}",
|
||||
canon_str(&entity_nbt::offers_to_nbt(&m.trader.offers))
|
||||
)?;
|
||||
}
|
||||
let mut line = format!("{name} draws");
|
||||
for id in trades.sequences() {
|
||||
write!(line, " {}", world.sequences.get(&id, seed).next_int())?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
"price" => {
|
||||
let mut m = world.merchant(&kv["v"], origin);
|
||||
let mut gossips = Gossips::default();
|
||||
prepare(&mut m, &mut gossips, &kv);
|
||||
let hero = kv.get("hero").and_then(|h| h.parse().ok());
|
||||
let rep = gossips.reputation(PLAYER);
|
||||
merchant::update_special_prices(&mut m.trader.offers, rep, hero);
|
||||
let mut line = format!("{name} rep {rep}");
|
||||
for o in &m.trader.offers {
|
||||
write!(line, " {}/{}", o.cost_a_count(&items), o.special_price_diff)?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
"restock" => {
|
||||
let mut m = world.merchant(&kv["v"], origin);
|
||||
let mut gossips = Gossips::default();
|
||||
prepare(&mut m, &mut gossips, &kv);
|
||||
let steps = kv.get("steps").cloned().unwrap_or_default();
|
||||
for (k, s) in steps.split(',').filter(|s| !s.is_empty()).enumerate() {
|
||||
let f: Vec<&str> = s.split(':').collect();
|
||||
let mut line = format!("{name} {k} {s}");
|
||||
match f[0] {
|
||||
"use" => {
|
||||
let i: usize = f[1].parse().unwrap_or(0);
|
||||
if let Some(o) = m.trader.offers.get_mut(i) {
|
||||
o.uses += f[2].parse::<i32>().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
"check" => {
|
||||
let time: i64 = f[1].parse().unwrap_or(0);
|
||||
let day: i64 = f[2].parse().unwrap_or(0);
|
||||
let should = m.trader.should_restock(time, day);
|
||||
if should {
|
||||
m.trader.restock(time);
|
||||
}
|
||||
write!(line, " {should}")?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
write!(
|
||||
line,
|
||||
" lr {} rt {} cd {}",
|
||||
m.trader.last_restock, m.trader.restocks_today, m.trader.check_day
|
||||
)?;
|
||||
for o in &m.trader.offers {
|
||||
write!(line, " {}/{}", o.uses, o.demand)?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
"nbt" => {
|
||||
let text = snbt.get(name).ok_or("missing vanilla snbt")?;
|
||||
let (tag, _) = horizon_world::nbt::parse_snbt(text)?;
|
||||
let id = if kv["v"] == "wt" {
|
||||
"minecraft:wandering_trader"
|
||||
} else {
|
||||
"minecraft:villager"
|
||||
};
|
||||
let mut tag = tag;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("id".into(), Tag::String(id.into()));
|
||||
}
|
||||
let mob = entity_nbt::mob_from_nbt(&tag, &types, 1).ok_or("unreadable villager")?;
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None);
|
||||
writeln!(out, "{name} snbt {text}")?;
|
||||
let mut kept = horizon_world::nbt::Compound::new();
|
||||
for key in [
|
||||
"Offers",
|
||||
"Xp",
|
||||
"LastRestock",
|
||||
"RestocksToday",
|
||||
"VillagerData",
|
||||
] {
|
||||
if let Some(v) = back.get(key) {
|
||||
kept.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
writeln!(out, "{name} canon {}", canon_str(&Tag::Compound(kept)))?;
|
||||
}
|
||||
"trade" => {
|
||||
let env_trade = TradeEnv {
|
||||
trades: &trades,
|
||||
enchantments: &enchantments,
|
||||
maps: None,
|
||||
};
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
trade: Some(&env_trade),
|
||||
};
|
||||
trade(&mut out, name, &kv, &mut world, &env, origin)?;
|
||||
}
|
||||
_ => return Err(format!("unknown case {kind}").into()),
|
||||
}
|
||||
}
|
||||
fs::write(args.get(4).ok_or(usage)?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trade(
|
||||
out: &mut String,
|
||||
name: &str,
|
||||
kv: &HashMap<String, String>,
|
||||
world: &mut World,
|
||||
env: &Env,
|
||||
origin: [f64; 3],
|
||||
) -> horizon_client::Result<()> {
|
||||
let vseed: i64 = kv.get("vseed").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let mut m = world.merchant(&kv["v"], origin);
|
||||
let mut gossips = Gossips::default();
|
||||
if m.villager {
|
||||
prepare(&mut m, &mut gossips, kv);
|
||||
}
|
||||
let hero: Option<i32> = kv.get("hero").and_then(|h| h.parse().ok());
|
||||
let mut menu = Menu::new(Kind::Merchant);
|
||||
if let Some(inv) = kv.get("inv") {
|
||||
for s in inv.split(',') {
|
||||
let (slot, rest) = s.split_once(':').ok_or("inv")?;
|
||||
let (item, count) = rest.rsplit_once('*').ok_or("inv")?;
|
||||
let item = if item.contains(':') {
|
||||
item.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{item}")
|
||||
};
|
||||
menu.set_player_item(slot.parse()?, Some(ItemStack::new(&item, count.parse()?)));
|
||||
}
|
||||
}
|
||||
menu.selected = kv.get("hand").and_then(|h| h.parse().ok()).unwrap_or(8);
|
||||
// Villager.mobInteract / WanderingTrader.mobInteract.
|
||||
let open = !m.trader.offers.is_empty();
|
||||
let unhappy = if m.villager && !open { 40 } else { 0 };
|
||||
let mut voice = LegacyRandom::new(vseed);
|
||||
if unhappy > 0 {
|
||||
// setUnhappy: makeSound(VILLAGER_NO) draws the voice pitch.
|
||||
voice.next_float();
|
||||
voice.next_float();
|
||||
}
|
||||
writeln!(out, "{name} open {open} unhappy {unhappy}")?;
|
||||
let mut session = Session::new(m.trader.offers.clone(), m.villager);
|
||||
session.xp = m.trader.xp;
|
||||
session.level = m.level;
|
||||
session.profession = m.profession.clone();
|
||||
session.variant = m.variant.clone();
|
||||
session.random = voice.clone();
|
||||
session.origin = origin;
|
||||
session.reputation = gossips.reputation(PLAYER);
|
||||
session.hero = hero;
|
||||
for id in Session::sequences_needed(world.trades, &m.profession, m.level) {
|
||||
let r = world.sequences.lend(&id, world.seed);
|
||||
session.sequences.insert(id, r);
|
||||
}
|
||||
session.update_special_prices();
|
||||
let mut regen = false;
|
||||
let mut open_menu = open;
|
||||
if open_menu {
|
||||
menu.merchant = Some(Box::new(session));
|
||||
}
|
||||
let steps = kv.get("steps").cloned().unwrap_or_default();
|
||||
let mut session_after: Option<Box<Session>> = None;
|
||||
for (k, s) in steps.split(',').filter(|s| !s.is_empty()).enumerate() {
|
||||
let f: Vec<&str> = s.split(':').collect();
|
||||
match f[0] {
|
||||
"select" if open_menu => menu.merchant_select(env, f[1].parse().unwrap_or(0)),
|
||||
"click" if open_menu => {
|
||||
let input = Input::parse(&f[3].to_uppercase()).ok_or("input")?;
|
||||
let _ = menu.click(env, f[1].parse()?, f[2].parse()?, input);
|
||||
}
|
||||
"tick" => {
|
||||
let sess = menu.merchant.as_mut().or(session_after.as_mut());
|
||||
if let Some(sess) = sess
|
||||
&& sess.villager
|
||||
&& sess.last_traded
|
||||
{
|
||||
sess.last_traded = false;
|
||||
gossips.add(PLAYER, GossipType::Trading, 2);
|
||||
sess.reputation = gossips.reputation(PLAYER);
|
||||
if open_menu {
|
||||
sess.update_special_prices();
|
||||
menu.merchant_update(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
"close" if open_menu => {
|
||||
menu.merchant_close(env);
|
||||
open_menu = false;
|
||||
session_after = Some(menu.merchant.take().ok_or("session")?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut line = format!("{name} {k} {s} menu {open_menu}");
|
||||
if open_menu {
|
||||
for i in 0..3 {
|
||||
write!(line, " {}", stack(menu.slot(i)))?;
|
||||
}
|
||||
write!(
|
||||
line,
|
||||
" fxp {}",
|
||||
menu.merchant.as_ref().map_or(0, |s| s.future_xp)
|
||||
)?;
|
||||
}
|
||||
write!(line, " carried {}", stack(menu.carried.as_ref()))?;
|
||||
line.push_str(" inv");
|
||||
for i in 0..36 {
|
||||
if let Some(st) = menu.player_item(i) {
|
||||
write!(line, " {i}={}", stack(Some(st)))?;
|
||||
}
|
||||
}
|
||||
let sess = menu
|
||||
.merchant
|
||||
.as_deref_mut()
|
||||
.or(session_after.as_deref_mut());
|
||||
let (xp, level, offers, orbs) = match sess {
|
||||
Some(sess) => {
|
||||
let mut orbs = Vec::new();
|
||||
for e in sess.events.drain(..) {
|
||||
match e {
|
||||
Event::Orb(v) => orbs.push(v),
|
||||
Event::LevelUp(_) => regen = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(sess.xp, sess.level, sess.offers.clone(), orbs)
|
||||
}
|
||||
None => (m.trader.xp, m.level, m.trader.offers.clone(), Vec::new()),
|
||||
};
|
||||
write!(line, " xp {xp}")?;
|
||||
if m.villager {
|
||||
write!(
|
||||
line,
|
||||
" lvl {level} rep {} regen {regen}",
|
||||
gossips.reputation(PLAYER)
|
||||
)?;
|
||||
}
|
||||
line.push_str(" offers");
|
||||
for o in &offers {
|
||||
write!(
|
||||
line,
|
||||
" {}/{}/{}",
|
||||
o.uses,
|
||||
o.special_price_diff,
|
||||
o.cost_a_count(env.items)
|
||||
)?;
|
||||
}
|
||||
line.push_str(" orbs");
|
||||
for v in orbs {
|
||||
write!(line, " {v}")?;
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
let random = match menu.merchant.as_mut().or(session_after.as_mut()) {
|
||||
Some(s) => s.random.next_int(),
|
||||
None => voice.next_int(),
|
||||
};
|
||||
writeln!(out, "{name} next {random}")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! WeatherEffectRenderer column instances and quads, without a GPU.
|
||||
use horizon_client::weather;
|
||||
use std::{fmt::Write, fs};
|
||||
fn bits(value: f32) -> String {
|
||||
if value.is_nan() {
|
||||
"nan".into()
|
||||
} else {
|
||||
format!("{:x}", value.to_bits())
|
||||
}
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let mut output = String::new();
|
||||
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
|
||||
let p: Vec<&str> = line.split_whitespace().collect();
|
||||
let int = |i: usize| p[i].parse::<i32>().unwrap();
|
||||
let kind = int(0);
|
||||
let ticks: i64 = p[1].parse()?;
|
||||
let (x, bottom, top, z, light) = (int(2), int(3), int(4), int(5), int(6));
|
||||
let partial: f32 = p[7].parse()?;
|
||||
let camera = [p[8].parse()?, p[9].parse()?, p[10].parse()?];
|
||||
let radius = int(11);
|
||||
let intensity: f32 = p[12].parse()?;
|
||||
let mut random = weather::column_random(x, z);
|
||||
let column = if kind == 0 {
|
||||
weather::rain_column(&mut random, ticks, x, bottom, top, z, light, partial)
|
||||
} else {
|
||||
weather::snow_column(&mut random, ticks, x, bottom, top, z, light, partial)
|
||||
};
|
||||
write!(
|
||||
output,
|
||||
"{case} {} {} {} {} {} {} {}",
|
||||
column.x,
|
||||
column.z,
|
||||
column.bottom_y,
|
||||
column.top_y,
|
||||
bits(column.u_offset),
|
||||
bits(column.v_offset),
|
||||
column.light_coords
|
||||
)?;
|
||||
let max_alpha = if kind == 0 { 1.0 } else { 0.8 };
|
||||
for v in weather::column_quad(&column, camera, max_alpha, radius, intensity) {
|
||||
write!(
|
||||
output,
|
||||
" |{},{},{},{},{},{:x},{}",
|
||||
bits(v.pos[0]),
|
||||
bits(v.pos[1]),
|
||||
bits(v.pos[2]),
|
||||
bits(v.uv[0]),
|
||||
bits(v.uv[1]),
|
||||
v.color,
|
||||
v.light_coords
|
||||
)?;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -294,6 +294,8 @@ pub struct EntityView {
|
||||
pub struck: Vec<EntityView>,
|
||||
pub fishing_open_water: Option<bool>,
|
||||
pub variant: Option<String>,
|
||||
/// The data components the entity reports (`Entity.get`): id and value.
|
||||
pub components: Vec<(String, String)>,
|
||||
}
|
||||
fn range_f(v: &Value, x: f64) -> bool {
|
||||
match v {
|
||||
@@ -310,7 +312,7 @@ fn range_i(v: &Value, x: i64) -> bool {
|
||||
range_f(v, x as f64)
|
||||
}
|
||||
/// A registry id, `#tag`, or list matched against one id.
|
||||
fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
match v {
|
||||
Value::Null => true,
|
||||
Value::String(s) => match s.strip_prefix('#') {
|
||||
@@ -442,6 +444,26 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `DataComponentExactPredicate.test` on an entity: every expected
|
||||
/// component equals what the entity reports (absent ones never match).
|
||||
fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
let full = |id: &str| {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
};
|
||||
expected.as_object().is_some_and(|o| {
|
||||
o.iter().all(|(id, want)| {
|
||||
let id = full(id);
|
||||
want.as_str().is_some_and(|want| {
|
||||
have.iter()
|
||||
.any(|(k, v)| full(k) == id && full(v) == full(want))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `EntityPredicate` (26.x keyed sub-predicates).
|
||||
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -521,7 +543,8 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
"minecraft:type_specific/fishing_hook" => p["in_open_water"]
|
||||
.as_bool()
|
||||
.is_none_or(|want| e.fishing_open_water == Some(want)),
|
||||
"minecraft:components" | "minecraft:predicates" => true,
|
||||
"minecraft:components" => components(p, &e.components),
|
||||
"minecraft:predicates" => true,
|
||||
_ => false,
|
||||
};
|
||||
if !ok {
|
||||
|
||||
@@ -180,15 +180,43 @@ impl Assets {
|
||||
.collect::<Result<_>>()?,
|
||||
));
|
||||
let mut material_states = [BlockStateId(0); 6];
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for (layer, name) in MATERIALS.iter().enumerate() {
|
||||
let state = registry
|
||||
material_states[layer] = registry
|
||||
.default_state(&format!("minecraft:{name}"))
|
||||
.ok_or_else(|| invalid("missing test-scene material"))?;
|
||||
material_states[layer] = state;
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
|
||||
Self::with_models(
|
||||
root,
|
||||
registry,
|
||||
properties,
|
||||
material_states,
|
||||
blocks_fluid_flow,
|
||||
)
|
||||
}
|
||||
/// The same blocks with the models and sprites of another resource root
|
||||
/// (resource packs changed): the registry and block facts come from the
|
||||
/// game data, which resource packs do not touch.
|
||||
pub fn reload(&self, root: &Path) -> Result<Self> {
|
||||
Self::with_models(
|
||||
root,
|
||||
self.registry,
|
||||
self.properties.clone(),
|
||||
self.material_states,
|
||||
self.blocks_fluid_flow.clone(),
|
||||
)
|
||||
}
|
||||
fn with_models(
|
||||
root: &Path,
|
||||
registry: &'static BlockRegistry,
|
||||
properties: Properties,
|
||||
material_states: [BlockStateId; 6],
|
||||
blocks_fluid_flow: BTreeSet<String>,
|
||||
) -> Result<Self> {
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let models = crate::models::prepare(root, registry, &mut sprites)?;
|
||||
// Fluids are drawn by the fluid renderer, not block models; breaking
|
||||
// progress uses the destroy-stage overlays.
|
||||
@@ -204,6 +232,7 @@ impl Assets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.finish();
|
||||
Ok(Self {
|
||||
registry,
|
||||
properties,
|
||||
|
||||
@@ -600,6 +600,16 @@ impl Audio {
|
||||
pub fn stop_all(&mut self) {
|
||||
self.engine.stop_all(&mut self.backend);
|
||||
}
|
||||
/// `SoundManager.reload` (resource packs changed): stop every sound, then
|
||||
/// read sound events and sound files from the new resource root.
|
||||
pub fn reload(&mut self, root: &Path) {
|
||||
self.stop_all();
|
||||
self.engine.registry = SoundRegistry::load(root);
|
||||
self.backend.root = root.to_owned();
|
||||
let mut cache = self.backend.cache.lock().unwrap();
|
||||
cache.entries.clear();
|
||||
cache.bytes = 0;
|
||||
}
|
||||
/// Play a sound event at a world position, as `ClientLevel.playLocalSound`
|
||||
/// (a `SimpleSoundInstance` seeded from the level random), or relative to
|
||||
/// the listener without attenuation (`None`).
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
//! horizon-ctl: drive a running Horizon client through its control socket.
|
||||
#![forbid(unsafe_code)]
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use horizon_client::control::UnixStream;
|
||||
|
||||
const USAGE: &str = "usage: horizon-ctl [--socket PATH] COMMAND [ARGS...]
|
||||
|
||||
Local commands:
|
||||
launch [GAME ARGS...] start horizon-client with a control socket and wait for it
|
||||
hold KEY SECONDS press KEY, wait, release it
|
||||
wait-ready [SECONDS] wait until the player has spawned and no chunks are pending
|
||||
- run commands from stdin, one per line (\"sleep S\" waits locally)
|
||||
|
||||
Game commands (run \"horizon-ctl help\" against a running game for the full list):
|
||||
status, key, mouse, look, turn, tp, hotbar, give, render-distance, time,
|
||||
screenshot PATH, stats, bench SECONDS, open, save, quit
|
||||
|
||||
Replies are JSON lines; the exit status is 1 when the reply has an \"error\".
|
||||
The socket defaults to $HORIZON_CONTROL or $XDG_RUNTIME_DIR/horizon-control.sock.";
|
||||
|
||||
struct Client {
|
||||
writer: UnixStream,
|
||||
reader: BufReader<UnixStream>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn connect(path: &Path) -> Result<Self, String> {
|
||||
let stream = UnixStream::connect(path).map_err(|e| {
|
||||
format!(
|
||||
"cannot connect to {} ({e}); is the game running with --control?",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
|
||||
Ok(Self {
|
||||
writer: stream,
|
||||
reader,
|
||||
})
|
||||
}
|
||||
fn send(&mut self, line: &str) -> Result<String, String> {
|
||||
writeln!(self.writer, "{line}").map_err(|e| e.to_string())?;
|
||||
let mut reply = String::new();
|
||||
if self
|
||||
.reader
|
||||
.read_line(&mut reply)
|
||||
.map_err(|e| e.to_string())?
|
||||
== 0
|
||||
{
|
||||
return Err("the game closed the connection".into());
|
||||
}
|
||||
Ok(reply.trim_end().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_error(reply: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(reply).map_or(true, |v| v.get("error").is_some())
|
||||
}
|
||||
|
||||
/// Screenshot paths are written by the game process: make them absolute here.
|
||||
fn absolute_paths(words: &mut [String]) {
|
||||
if words.first().map(String::as_str) == Some("screenshot")
|
||||
&& let Some(path) = words.get_mut(1)
|
||||
&& let Ok(cwd) = std::env::current_dir()
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn run_line(client: &mut Client, words: &mut [String]) -> Result<bool, String> {
|
||||
match words.first().map(String::as_str) {
|
||||
Some("sleep") => {
|
||||
let seconds: f64 = words
|
||||
.get(1)
|
||||
.ok_or("sleep SECONDS")?
|
||||
.parse()
|
||||
.map_err(|_| "sleep SECONDS")?;
|
||||
thread::sleep(Duration::from_secs_f64(seconds));
|
||||
Ok(true)
|
||||
}
|
||||
Some("hold") => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, seconds] => (
|
||||
key.clone(),
|
||||
seconds.parse::<f64>().map_err(|_| "hold KEY SECONDS")?,
|
||||
),
|
||||
_ => return Err("hold KEY SECONDS".into()),
|
||||
};
|
||||
let down = client.send(&format!("key {key} down"))?;
|
||||
if is_error(&down) {
|
||||
println!("{down}");
|
||||
return Ok(false);
|
||||
}
|
||||
thread::sleep(Duration::from_secs_f64(seconds));
|
||||
let up = client.send(&format!("key {key} up"))?;
|
||||
println!("{up}");
|
||||
Ok(!is_error(&up))
|
||||
}
|
||||
Some("wait-ready") => {
|
||||
let limit = words
|
||||
.get(1)
|
||||
.map_or(Ok(120.0), |s| s.parse::<f64>())
|
||||
.map_err(|_| "wait-ready [SECONDS]")?;
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(limit);
|
||||
loop {
|
||||
let reply = client.send("status")?;
|
||||
let status: serde_json::Value =
|
||||
serde_json::from_str(&reply).map_err(|e| e.to_string())?;
|
||||
if !status["player"].is_null() && status["pending_chunks"] == 0 {
|
||||
println!("{reply}");
|
||||
return Ok(true);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
println!(
|
||||
"{{\"error\":\"not ready after {limit} seconds\",\"status\":{reply}}}"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
absolute_paths(words);
|
||||
let reply = client.send(&words.join(" "))?;
|
||||
println!("{reply}");
|
||||
Ok(!is_error(&reply))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn launch(socket: &Path, args: &[String]) -> Result<bool, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let game = exe.with_file_name("horizon-client");
|
||||
let log = socket.with_extension("log");
|
||||
let file = std::fs::File::create(&log).map_err(|e| format!("{}: {e}", log.display()))?;
|
||||
let child = Command::new(&game)
|
||||
.args(args)
|
||||
.arg("--control-socket")
|
||||
.arg(socket)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(file.try_clone().map_err(|e| e.to_string())?)
|
||||
.stderr(file)
|
||||
.spawn()
|
||||
.map_err(|e| format!("{}: {e}", game.display()))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(300);
|
||||
while Instant::now() < deadline {
|
||||
if UnixStream::connect(socket).is_ok() {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "ok": true, "pid": child.id(), "socket": socket, "log": log })
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "error": "control socket did not open within 300 seconds", "log": log })
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut socket = horizon_client::control::default_path();
|
||||
if args.first().map(String::as_str) == Some("--socket") && args.len() >= 2 {
|
||||
socket = PathBuf::from(args.remove(1));
|
||||
args.remove(0);
|
||||
}
|
||||
let result = match args.first().map(String::as_str) {
|
||||
None | Some("-h" | "--help") => {
|
||||
println!("{USAGE}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
Some("launch") => launch(&socket, &args[1..]),
|
||||
Some("-") => Client::connect(&socket).and_then(|mut client| {
|
||||
let mut all_ok = true;
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let line = line.map_err(|e| e.to_string())?;
|
||||
let mut words: Vec<String> = line.split_whitespace().map(str::to_owned).collect();
|
||||
if words.is_empty() || words[0].starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
all_ok &= run_line(&mut client, &mut words)?;
|
||||
}
|
||||
Ok(all_ok)
|
||||
}),
|
||||
Some(_) => Client::connect(&socket).and_then(|mut client| run_line(&mut client, &mut args)),
|
||||
};
|
||||
match result {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::from(1),
|
||||
Err(e) => {
|
||||
eprintln!("horizon-ctl: {e}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
//! Every command horizon-ctl knows: its usage, help, examples and arguments.
|
||||
//! The same table prints `help`, checks `COMMAND --help` and describes the
|
||||
//! MCP tools, so the CLI and the MCP server always offer the same commands.
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Ty {
|
||||
Int,
|
||||
Num,
|
||||
Str,
|
||||
Bool,
|
||||
/// One of these words.
|
||||
Enum(&'static [&'static str]),
|
||||
/// The rest of the line, spaces included.
|
||||
Rest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Param {
|
||||
pub name: &'static str,
|
||||
pub ty: Ty,
|
||||
pub required: bool,
|
||||
/// Sent as `--name value` (`--name` alone for booleans), not by position.
|
||||
pub flag: bool,
|
||||
pub help: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Group {
|
||||
Session,
|
||||
Observe,
|
||||
Move,
|
||||
Act,
|
||||
Screens,
|
||||
World,
|
||||
Scripting,
|
||||
}
|
||||
impl Group {
|
||||
pub const ALL: [Group; 7] = [
|
||||
Group::Session,
|
||||
Group::Observe,
|
||||
Group::Move,
|
||||
Group::Act,
|
||||
Group::Screens,
|
||||
Group::World,
|
||||
Group::Scripting,
|
||||
];
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Group::Session => "Session",
|
||||
Group::Observe => "Observe (JSON state)",
|
||||
Group::Move => "Move and look",
|
||||
Group::Act => "Act on the world",
|
||||
Group::Screens => "Screens, menus and chat",
|
||||
Group::World => "World and cheats",
|
||||
Group::Scripting => "Scripting and benchmarks",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Cmd {
|
||||
pub name: &'static str,
|
||||
/// What the game receives instead of `name` (aliases such as look-at-block).
|
||||
pub wire: Option<&'static str>,
|
||||
pub group: Group,
|
||||
pub usage: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub details: &'static str,
|
||||
pub examples: &'static [&'static str],
|
||||
pub params: &'static [Param],
|
||||
/// Offered as an MCP tool.
|
||||
pub mcp: bool,
|
||||
/// Runs in horizon-ctl itself, not in the game.
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
const fn p(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
Param {
|
||||
name,
|
||||
ty,
|
||||
required: true,
|
||||
flag: false,
|
||||
help,
|
||||
}
|
||||
}
|
||||
const fn opt(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
Param {
|
||||
name,
|
||||
ty,
|
||||
required: false,
|
||||
flag: false,
|
||||
help,
|
||||
}
|
||||
}
|
||||
const fn flag(name: &'static str, ty: Ty, help: &'static str) -> Param {
|
||||
Param {
|
||||
name,
|
||||
ty,
|
||||
required: false,
|
||||
flag: true,
|
||||
help,
|
||||
}
|
||||
}
|
||||
const XYZ: [Param; 3] = [
|
||||
p("x", Ty::Num, "X coordinate"),
|
||||
p("y", Ty::Num, "Y coordinate"),
|
||||
p("z", Ty::Num, "Z coordinate"),
|
||||
];
|
||||
const BLOCK_XYZ: [Param; 3] = [
|
||||
p("x", Ty::Int, "block X"),
|
||||
p("y", Ty::Int, "block Y"),
|
||||
p("z", Ty::Int, "block Z"),
|
||||
];
|
||||
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
|
||||
|
||||
macro_rules! cmd {
|
||||
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
|
||||
Cmd {
|
||||
name: $name,
|
||||
wire: cmd!(@wire $($wire)?),
|
||||
group: Group::$group,
|
||||
usage: $usage,
|
||||
summary: $summary,
|
||||
details: $details,
|
||||
examples: &[$($ex),*],
|
||||
params: $params,
|
||||
mcp: $mcp,
|
||||
local: $local,
|
||||
}
|
||||
};
|
||||
(@wire) => { None };
|
||||
(@wire $w:expr) => { Some($w) };
|
||||
}
|
||||
|
||||
pub const COMMANDS: &[Cmd] = &[
|
||||
// Session
|
||||
cmd!("launch", Session, "launch [--world NAME] [--seed N] [--creative] [--version TAG] [--game PATH] [--wait] [GAME ARGS...]",
|
||||
"start Horizon with the control socket and wait until it answers",
|
||||
"Finds the game in this order: --game PATH; a horizon-client next to horizon-ctl (a build folder, a release folder or a launcher install); the newest version installed by the Horizon launcher (--version TAG picks one). Launcher installs run with their prepared resources and keep saves in the game home ($HORIZON_HOME, else ~/.local/share/horizon). --wait also waits until the player has spawned and chunks are loaded. Other arguments go to the game (horizon-client --help lists them). The game's output goes to a .log file beside the socket.",
|
||||
["horizon-ctl launch --world Survival --seed 42 --wait", "horizon-ctl launch --world Flat --creative --render-distance 8"],
|
||||
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
|
||||
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
|
||||
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)")],
|
||||
mcp: true, local: true),
|
||||
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
|
||||
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
|
||||
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"", ["horizon-ctl status"], &[], mcp: false, local: false),
|
||||
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
|
||||
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
|
||||
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
|
||||
&[p("world", Ty::Str, "world folder"), opt("seed", Ty::Int, "seed for a new world"), opt("mode", Ty::Enum(&["creative"]), "creative for a creative world")], mcp: true, local: false),
|
||||
cmd!("save", Session, "save", "save the world", "", ["horizon-ctl save"], &[], mcp: true, local: false),
|
||||
cmd!("quit", Session, "quit", "save and exit the game", "", ["horizon-ctl quit"], &[], mcp: true, local: false),
|
||||
cmd!("screenshot", Session, "screenshot PATH", "write the next rendered frame as a PNG",
|
||||
"Relative paths are taken from horizon-ctl's directory. Over MCP the image is returned directly.",
|
||||
["horizon-ctl screenshot view.png"], &[], mcp: true, local: false),
|
||||
// Observe
|
||||
cmd!("state", Observe, "state", "player, world, screen and crosshair target in one reply",
|
||||
"position, block_pos, eye, yaw, pitch, velocity, on_ground, health, food, saturation, xp, effects, gamemode, dimension, time, weather, the screen or title menu, the held item, what the crosshair points at and whether an action is running.",
|
||||
["horizon-ctl state"], &[], mcp: true, local: false),
|
||||
cmd!("looking-at", Observe, "looking-at", "the block or entity under the crosshair",
|
||||
"kind is block, entity or none; a block has pos, state, face, point and distance; an entity has id, type, health and distance.",
|
||||
["horizon-ctl looking-at"], &[], mcp: true, local: false),
|
||||
cmd!("entities", Observe, "entities [RADIUS] [TYPE]", "mobs, dropped items and experience orbs near the player, nearest first",
|
||||
"RADIUS defaults to 32. TYPE filters by entity type (pig, minecraft:item, ...). Mobs have an id for look-at entity and attack.",
|
||||
["horizon-ctl entities", "horizon-ctl entities 16 cow", "horizon-ctl entities 8 item"],
|
||||
&[opt("radius", Ty::Num, "search radius in blocks (default 32)"), opt("type", Ty::Str, "entity type filter")], mcp: true, local: false),
|
||||
cmd!("block", Observe, "block X Y Z", "the block state at a position", "",
|
||||
["horizon-ctl block 10 64 -3"], &BLOCK_XYZ, mcp: true, local: false),
|
||||
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
|
||||
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
|
||||
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
|
||||
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
|
||||
&[p("block", Ty::Str, "block id such as oak_log"), opt("radius", Ty::Int, "search radius (default 32)")], mcp: true, local: false),
|
||||
cmd!("inventory", Observe, "inventory", "hotbar, main inventory, armor, offhand, carried stack and item counts",
|
||||
"Slots carry the ids click-slot takes: 0-8 hotbar, 9-35 inventory, 500-503 armor (head to feet), 504 offhand. Stacks with components list them as SNBT.",
|
||||
["horizon-ctl inventory"], &[], mcp: true, local: false),
|
||||
cmd!("screen", Observe, "screen", "the open screen or title menu, its buttons and its slots",
|
||||
"buttons are what click-button takes; slots are what click-slot takes (crafting grid 100-103 in the inventory, 100-108 at a crafting table, result 200, containers from 300, stations from 600). On the title screen, menu has the mode, worlds and the create-world fields.",
|
||||
["horizon-ctl screen"], &[], mcp: true, local: false),
|
||||
cmd!("messages", Observe, "messages [N]", "recent chat and system messages, the title and the action bar",
|
||||
"N defaults to 20; chat is newest first.", ["horizon-ctl messages 5"], &[opt("n", Ty::Int, "how many chat lines (default 20)")], mcp: true, local: false),
|
||||
cmd!("menu", Observe, "menu", "the open menu's slots and carried stack, with station details", "",
|
||||
["horizon-ctl menu"], &[], mcp: false, local: false),
|
||||
// Move
|
||||
cmd!("move", Move, "move forward|back|left|right SECONDS [--sprint] [--sneak] [--jump]", "walk in a direction relative to the view for a time",
|
||||
"Replies when done with the start and end positions.",
|
||||
["horizon-ctl move forward 2", "horizon-ctl move forward 1.5 --sprint --jump"],
|
||||
&[p("direction", Ty::Enum(&["forward", "back", "left", "right"]), "direction relative to the view"), p("seconds", Ty::Num, "how long to walk"),
|
||||
flag("sprint", Ty::Bool, "sprint"), flag("sneak", Ty::Bool, "sneak"), flag("jump", Ty::Bool, "keep jumping")], mcp: true, local: false),
|
||||
cmd!("goto", Move, "goto X Y Z [--near R] [--sprint] [--direct] [--timeout S]", "walk to a position, finding a path through loaded terrain",
|
||||
"A* over standable blocks (steps up one block, drops up to three, avoids lava, swims when it must), then steering with jumps when stuck. Without a path it fails at once (--direct walks straight at the target anyway, falls included). --near R stops within R blocks (use 1.5-3 to reach a block you then break). Replies with arrived, position and distance, or an error when stuck or out of time (default 60 s).",
|
||||
["horizon-ctl goto 120 70 -40", "horizon-ctl goto 8 64 3 --near 2 --sprint"],
|
||||
&[XYZ[0], XYZ[1], XYZ[2], flag("near", Ty::Num, "arrive within this many blocks (default 0.6)"), flag("sprint", Ty::Bool, "sprint"), flag("direct", Ty::Bool, "walk straight when no path is found"), flag("timeout", Ty::Num, "give up after this many seconds (default 60)")], mcp: true, local: false),
|
||||
cmd!("jump", Move, "jump", "jump once and reply when landed", "", ["horizon-ctl jump"], &[], mcp: true, local: false),
|
||||
cmd!("look", Move, "look YAW PITCH", "set the view direction in degrees",
|
||||
"Yaw 0 faces south (+Z), 90 west (-X), 180 north, -90 east; pitch 90 looks straight down.",
|
||||
["horizon-ctl look 90 10"], &[p("yaw", Ty::Num, "yaw in degrees"), p("pitch", Ty::Num, "pitch in degrees (-90 up .. 90 down)")], mcp: true, local: false),
|
||||
cmd!("turn", Move, "turn DYAW DPITCH", "rotate the view by an amount", "", ["horizon-ctl turn 45 0"],
|
||||
&[p("dyaw", Ty::Num, "yaw change"), p("dpitch", Ty::Num, "pitch change")], mcp: false, local: false),
|
||||
cmd!("look-at", Move, "look-at X Y Z | look-at block X Y Z [FACE] | look-at entity ID", "turn to face a point, a block (a visible face of it) or an entity",
|
||||
"look-at block aims at a face the crosshair can reach and fails when none is visible.",
|
||||
["horizon-ctl look-at 10.5 65 3.5", "horizon-ctl look-at block 10 64 3 up", "horizon-ctl look-at entity 42"],
|
||||
&XYZ, mcp: true, local: false),
|
||||
cmd!("look-at-block", Move, "look-at-block X Y Z [FACE]", "aim the crosshair at a block (same as look-at block)", "",
|
||||
["horizon-ctl look-at-block 10 64 3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to aim at")], mcp: true, local: false, wire: "look-at block"),
|
||||
cmd!("look-at-entity", Move, "look-at-entity ID", "face an entity (same as look-at entity)", "",
|
||||
["horizon-ctl look-at-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: false, wire: "look-at entity"),
|
||||
cmd!("tp", Move, "tp X Y Z", "teleport the player", "", ["horizon-ctl tp 0 80 0"], &XYZ, mcp: true, local: false),
|
||||
// Act
|
||||
cmd!("break", Act, "break X Y Z [--timeout S]", "mine a block, holding the attack button until it breaks",
|
||||
"Aims at a visible face (the block must be within reach: use goto --near 2 first), mines with the held item, then waits up to a second for the drops. Replies with block_before, block, ticks, drops and picked_up (inventory change).",
|
||||
["horizon-ctl break 10 64 3"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("timeout", Ty::Num, "give up after this many seconds (default 30)")], mcp: true, local: false),
|
||||
cmd!("place", Act, "place X Y Z [--item ITEM]", "place the held item (or ITEM from the hotbar) at an empty position",
|
||||
"Clicks the face of a solid neighbour touching X Y Z. Replies with the block placed and the inventory change.",
|
||||
["horizon-ctl place 10 64 4 --item oak_planks"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], flag("item", Ty::Str, "hotbar item to select first")], mcp: true, local: false),
|
||||
cmd!("interact", Act, "interact X Y Z [FACE]", "right-click a block: open a chest or crafting table, press a button, open a door",
|
||||
"Replies with the screen afterwards and whether the block changed.", ["horizon-ctl interact 5 64 2"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("face", FACE, "which face to click")], mcp: true, local: false),
|
||||
cmd!("use-item", Act, "use-item [--hold SECONDS]", "right-click with the held item: eat, drink, throw, draw a bow",
|
||||
"Hold for 1.7 s to eat.", ["horizon-ctl use-item --hold 1.7"], &[flag("hold", Ty::Num, "hold the button this long")], mcp: true, local: false),
|
||||
cmd!("attack", Act, "attack [ID]", "swing at what the crosshair targets (after facing entity ID)",
|
||||
"Replies with the target's health before and after.", ["horizon-ctl attack", "horizon-ctl attack 42"],
|
||||
&[opt("id", Ty::Int, "entity id to face first")], mcp: true, local: false),
|
||||
cmd!("select", Act, "select SLOT|ITEM", "select a hotbar slot 1-9, or the hotbar slot holding ITEM", "",
|
||||
["horizon-ctl select 3", "horizon-ctl select oak_planks"], &[p("slot", Ty::Str, "1-9 or an item id")], mcp: true, local: false),
|
||||
cmd!("drop", Act, "drop [all]", "drop one of the held item, or the whole stack", "", ["horizon-ctl drop", "horizon-ctl drop all"],
|
||||
&[opt("all", Ty::Bool, "drop the whole stack")], mcp: true, local: false),
|
||||
cmd!("hold", Act, "hold KEY SECONDS", "press a key, wait, release it", "Keys as for key.", ["horizon-ctl hold w 2"],
|
||||
&[p("key", Ty::Str, "key name"), p("seconds", Ty::Num, "how long")], mcp: false, local: true),
|
||||
cmd!("key", Act, "key KEY down|up|tap", "press or release a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)", "",
|
||||
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
|
||||
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
|
||||
// Screens
|
||||
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
|
||||
["horizon-ctl open-inventory"], &[], mcp: true, local: false),
|
||||
cmd!("click-slot", Screens, "click-slot SLOT [right] [shift]", "click a slot of the open inventory or menu",
|
||||
"SLOT ids as listed by screen: 0-8 hotbar, 9-35 inventory, 100+ crafting grid, 200 result, 300+ container, 500-503 armor, 504 offhand, 600+ station. A left click picks up or puts down the carried stack; right puts down one item; shift moves the stack across.",
|
||||
["horizon-ctl click-slot 36", "horizon-ctl click-slot 100 right", "horizon-ctl click-slot 200 shift"],
|
||||
&[p("slot", Ty::Int, "slot id"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click")], mcp: true, local: false),
|
||||
cmd!("close", Screens, "close", "close the open menu and return to the game", "", ["horizon-ctl close"], &[], mcp: true, local: false),
|
||||
cmd!("click-button", Screens, "click-button LABEL", "click a button of the open screen or title menu by its label",
|
||||
"Case-insensitive; a unique part of the label is enough. screen lists the buttons.",
|
||||
["horizon-ctl click-button Singleplayer", "horizon-ctl click-button \"Create New World\"", "horizon-ctl click-button respawn"],
|
||||
&[p("label", Ty::Rest, "button label")], mcp: true, local: false),
|
||||
cmd!("click-at", Screens, "click-at X Y [right]", "click at GUI pixel X Y of the window (screen gives button rectangles)", "",
|
||||
["horizon-ctl click-at 200 120"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), opt("right", Ty::Bool, "right-click")], mcp: true, local: false),
|
||||
cmd!("type", Screens, "type [--field name|seed] [--replace] TEXT", "type into the focused text field (chat box, world name, anvil, sign...)",
|
||||
"On the title's create-world screen, --field name|seed picks the field and --replace clears it first; type \\b deletes one character.",
|
||||
["horizon-ctl type hello everyone", "horizon-ctl type --field name --replace Agent World", "horizon-ctl type --field seed 12345"], &[flag("field", Ty::Enum(&["name", "seed"]), "create-world field to type into"), flag("replace", Ty::Bool, "clear the field first"), p("text", Ty::Rest, "text to type")], mcp: true, local: false),
|
||||
cmd!("chat", Screens, "chat TEXT", "send a chat line (or a /command) as if typed", "", ["horizon-ctl chat hello"],
|
||||
&[p("text", Ty::Rest, "message")], mcp: true, local: false),
|
||||
cmd!("command", Screens, "command TEXT", "run a command (the leading / is optional)", "",
|
||||
["horizon-ctl command time set day", "horizon-ctl command /give @s oak_log 4"], &[p("command", Ty::Rest, "command line")], mcp: true, local: false),
|
||||
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
|
||||
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
|
||||
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
|
||||
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
|
||||
// World
|
||||
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
|
||||
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
|
||||
cmd!("time", World, "time TICKS", "set the time of day (0 sunrise, 6000 noon, 13000 night)", "", ["horizon-ctl time 6000"],
|
||||
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
|
||||
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)", "", ["horizon-ctl wait 40"],
|
||||
&[p("ticks", Ty::Int, "game ticks")], mcp: true, local: false),
|
||||
// Scripting
|
||||
cmd!("script", Scripting, "script [FILE] [--keep-going]", "run commands from FILE (or stdin), one per line",
|
||||
"Lines are split like a shell (quotes keep spaces); # starts a comment; sleep SECONDS waits. Stops at the first error unless --keep-going. horizon-ctl - is the older form: read stdin and keep going.",
|
||||
["horizon-ctl script play.txt", "printf 'state\\ngoto 0 64 0\\n' | horizon-ctl script"], &[], mcp: false, local: true),
|
||||
cmd!("sleep", Scripting, "sleep SECONDS", "wait (in scripts)", "", ["sleep 0.5"], &[], mcp: false, local: true),
|
||||
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH]", "fly the camera along a line, then reply with the frame summary", "",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
];
|
||||
|
||||
pub fn find(name: &str) -> Option<&'static Cmd> {
|
||||
COMMANDS.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// `horizon-ctl help`.
|
||||
pub fn overview() -> String {
|
||||
let mut out = String::from(
|
||||
"horizon-ctl: play and script a running Horizon from the command line.\n\n\
|
||||
usage: horizon-ctl [--socket PATH] [--timeout SECONDS] [--pretty] COMMAND [ARGS...]\n\
|
||||
\x20 horizon-ctl COMMAND --help (or: horizon-ctl help COMMAND)\n",
|
||||
);
|
||||
for group in Group::ALL {
|
||||
out.push_str(&format!("\n{}:\n", group.title()));
|
||||
for c in COMMANDS.iter().filter(|c| c.group == group) {
|
||||
out.push_str(&format!(" {:<16} {}\n", c.name, c.summary));
|
||||
}
|
||||
}
|
||||
out.push_str(
|
||||
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
|
||||
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
|
||||
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
|
||||
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
|
||||
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
|
||||
uses that transport).\n\n\
|
||||
First steps:\n\
|
||||
\x20 horizon-ctl launch --world Test --seed 1 --wait\n\
|
||||
\x20 horizon-ctl state\n\
|
||||
\x20 horizon-ctl find-block oak_log\n\
|
||||
\x20 horizon-ctl goto X Y Z --near 2 && horizon-ctl break X Y Z\n\
|
||||
\x20 horizon-ctl inventory\n",
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// `horizon-ctl COMMAND --help`.
|
||||
pub fn detail(c: &Cmd) -> String {
|
||||
let mut out = format!(
|
||||
"usage: horizon-ctl {}\n\n{}.\n",
|
||||
c.usage,
|
||||
capitalize(c.summary)
|
||||
);
|
||||
if !c.details.is_empty() {
|
||||
out.push('\n');
|
||||
out.push_str(&wrap(c.details, 78));
|
||||
}
|
||||
if !c.examples.is_empty() {
|
||||
out.push_str("\nExamples:\n");
|
||||
for e in c.examples {
|
||||
out.push_str(&format!(" {e}\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
chars
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().chain(chars).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn wrap(text: &str, width: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if !line.is_empty() && line.len() + 1 + word.len() > width {
|
||||
out.push_str(&line);
|
||||
out.push('\n');
|
||||
line.clear();
|
||||
}
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
}
|
||||
if !line.is_empty() {
|
||||
out.push_str(&line);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The MCP tool name of a command (`look-at-block` gives `look_at_block`).
|
||||
pub fn tool_name(c: &Cmd) -> String {
|
||||
c.name.replace('-', "_")
|
||||
}
|
||||
|
||||
/// The JSON schema of a command's arguments.
|
||||
pub fn schema(c: &Cmd) -> Value {
|
||||
let mut props = Map::new();
|
||||
let mut required = Vec::new();
|
||||
for p in c.params {
|
||||
let mut s = match p.ty {
|
||||
Ty::Int => json!({ "type": "integer" }),
|
||||
Ty::Num => json!({ "type": "number" }),
|
||||
Ty::Str | Ty::Rest => json!({ "type": "string" }),
|
||||
Ty::Bool => json!({ "type": "boolean" }),
|
||||
Ty::Enum(values) => json!({ "type": "string", "enum": values }),
|
||||
};
|
||||
s["description"] = json!(p.help);
|
||||
props.insert(p.name.to_owned(), s);
|
||||
if p.required {
|
||||
required.push(p.name);
|
||||
}
|
||||
}
|
||||
json!({ "type": "object", "properties": props, "required": required })
|
||||
}
|
||||
|
||||
/// The command line for an MCP call's arguments: positional values in order,
|
||||
/// then `--flag value` options.
|
||||
pub fn line_for(c: &Cmd, args: &Value) -> Result<Vec<String>, String> {
|
||||
let mut words: Vec<String> = c
|
||||
.wire
|
||||
.unwrap_or(c.name)
|
||||
.split(' ')
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut flags = Vec::new();
|
||||
let mut gap: Option<&str> = None;
|
||||
let mut rest: Option<String> = None;
|
||||
for p in c.params {
|
||||
let value = args.get(p.name).filter(|v| !v.is_null());
|
||||
let text = match value {
|
||||
None => {
|
||||
if p.required {
|
||||
return Err(format!("{} is required", p.name));
|
||||
}
|
||||
if !p.flag {
|
||||
gap.get_or_insert(p.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some(Value::Bool(b)) => {
|
||||
if p.ty != Ty::Bool {
|
||||
return Err(format!("{} must be a {}", p.name, ty_name(p.ty)));
|
||||
}
|
||||
if *b {
|
||||
if p.flag {
|
||||
flags.push(format!("--{}", p.name));
|
||||
} else {
|
||||
words.push(p.name.to_owned());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(other) => {
|
||||
return Err(format!(
|
||||
"{} must be a {}, not {other}",
|
||||
p.name,
|
||||
ty_name(p.ty)
|
||||
));
|
||||
}
|
||||
};
|
||||
match p.ty {
|
||||
Ty::Int if text.parse::<i64>().is_err() => {
|
||||
return Err(format!("{} must be an integer, not {text}", p.name));
|
||||
}
|
||||
Ty::Num if text.parse::<f64>().is_err() => {
|
||||
return Err(format!("{} must be a number, not {text}", p.name));
|
||||
}
|
||||
Ty::Enum(values) if !values.contains(&text.as_str()) => {
|
||||
return Err(format!("{} must be one of {values:?}, not {text}", p.name));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if p.flag {
|
||||
flags.push(format!("--{}", p.name));
|
||||
flags.push(text);
|
||||
} else {
|
||||
if let Some(missing) = gap {
|
||||
return Err(format!("{} needs {missing} to be given too", p.name));
|
||||
}
|
||||
if p.ty == Ty::Rest {
|
||||
rest = Some(text);
|
||||
} else if text.contains(char::is_whitespace) || text.is_empty() {
|
||||
return Err(format!("{} must be a single word", p.name));
|
||||
} else {
|
||||
words.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Options go before free text, which runs to the end of the line.
|
||||
words.extend(flags);
|
||||
words.extend(rest);
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
fn ty_name(ty: Ty) -> &'static str {
|
||||
match ty {
|
||||
Ty::Int => "integer",
|
||||
Ty::Num => "number",
|
||||
Ty::Bool => "boolean",
|
||||
_ => "string",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_are_unique_and_documented() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for c in COMMANDS {
|
||||
assert!(seen.insert(c.name), "duplicate command {}", c.name);
|
||||
assert!(
|
||||
c.usage.starts_with(c.name.split('-').next().unwrap()),
|
||||
"{}",
|
||||
c.name
|
||||
);
|
||||
assert!(
|
||||
!c.summary.is_empty() && !c.examples.is_empty(),
|
||||
"{}",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_arguments_become_command_lines() {
|
||||
let goto = find("goto").unwrap();
|
||||
let words = line_for(
|
||||
goto,
|
||||
&json!({ "x": 1.5, "y": 64, "z": -3, "near": 2, "sprint": true }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
words,
|
||||
["goto", "1.5", "64", "-3", "--near", "2", "--sprint"]
|
||||
);
|
||||
assert!(
|
||||
line_for(goto, &json!({ "x": 1, "y": 2 }))
|
||||
.unwrap_err()
|
||||
.contains("z is required")
|
||||
);
|
||||
assert!(line_for(goto, &json!({ "x": "a", "y": 2, "z": 3 })).is_err());
|
||||
let block = find("look-at-block").unwrap();
|
||||
assert_eq!(
|
||||
line_for(block, &json!({ "x": 1, "y": 2, "z": 3, "face": "up" })).unwrap(),
|
||||
["look-at", "block", "1", "2", "3", "up"]
|
||||
);
|
||||
assert!(
|
||||
line_for(
|
||||
block,
|
||||
&json!({ "x": 1, "y": 2, "z": 3, "face": "sideways" })
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let button = find("click-button").unwrap();
|
||||
assert_eq!(
|
||||
line_for(button, &json!({ "label": "Create New World" })).unwrap(),
|
||||
["click-button", "Create New World"]
|
||||
);
|
||||
let slot = find("click-slot").unwrap();
|
||||
assert_eq!(
|
||||
line_for(slot, &json!({ "slot": 200, "shift": true })).unwrap(),
|
||||
["click-slot", "200", "shift"]
|
||||
);
|
||||
let open = find("open").unwrap();
|
||||
assert!(
|
||||
line_for(open, &json!({ "world": "A", "mode": "creative" }))
|
||||
.unwrap_err()
|
||||
.contains("seed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_list_required_parameters() {
|
||||
let s = schema(find("break").unwrap());
|
||||
assert_eq!(s["required"], json!(["x", "y", "z"]));
|
||||
assert_eq!(s["properties"]["timeout"]["type"], "number");
|
||||
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_every_command() {
|
||||
let text = overview();
|
||||
for c in COMMANDS {
|
||||
assert!(text.contains(c.name));
|
||||
assert!(detail(c).contains("usage: horizon-ctl"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! `horizon-ctl launch`: find a Horizon to start and start it with a control
|
||||
//! socket. Mirrors where the launcher puts things (launcher/src/backend.rs):
|
||||
//! versions under `<launcher home>/versions/<tag>`, each with its prepared
|
||||
//! resources in `.local/horizon-client/<minecraft version>` and run from the
|
||||
//! game home, where saves and options.txt live.
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(windows)]
|
||||
const CLIENT: &str = "horizon-client.exe";
|
||||
#[cfg(not(windows))]
|
||||
const CLIENT: &str = "horizon-client";
|
||||
|
||||
/// The platform data folder (what the `dirs` crate's data_dir gives).
|
||||
fn data_dir() -> PathBuf {
|
||||
let env = |k: &str| {
|
||||
std::env::var_os(k)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
};
|
||||
let home = env("HOME").or_else(|| env("USERPROFILE"));
|
||||
if cfg!(windows) {
|
||||
if let Some(d) = env("APPDATA") {
|
||||
return d;
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
if let Some(h) = &home {
|
||||
return h.join("Library/Application Support");
|
||||
}
|
||||
} else if let Some(d) = env("XDG_DATA_HOME") {
|
||||
return d;
|
||||
} else if let Some(h) = &home {
|
||||
return h.join(".local/share");
|
||||
}
|
||||
std::env::temp_dir()
|
||||
}
|
||||
pub fn launcher_home() -> PathBuf {
|
||||
match std::env::var_os("HORIZON_LAUNCHER_HOME").filter(|h| !h.is_empty()) {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => data_dir().join("horizon-launcher"),
|
||||
}
|
||||
}
|
||||
pub fn game_home() -> PathBuf {
|
||||
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => data_dir().join("horizon"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `v2026.10.13.1` gives [2026, 10, 13, 1]; a pre-release suffix is ignored.
|
||||
pub fn version_parts(tag: &str) -> Vec<u64> {
|
||||
tag.trim_start_matches('v')
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split('.')
|
||||
.map(|p| p.parse().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Installed launcher versions, newest first.
|
||||
pub fn installed(versions: &Path) -> Vec<String> {
|
||||
let mut tags: Vec<String> = std::fs::read_dir(versions)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().join(".installed").exists() && e.path().join(CLIENT).exists())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect();
|
||||
tags.sort_by_key(|t| std::cmp::Reverse(version_parts(t)));
|
||||
tags
|
||||
}
|
||||
|
||||
/// A folder's prepared resources, as the launcher prepares them.
|
||||
pub fn prepared_resources(dir: &Path) -> Option<PathBuf> {
|
||||
let version = std::fs::read_to_string(dir.join("research-lock.json"))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
|
||||
.and_then(|v| v["minecraft"]["version"].as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| "26.3".into());
|
||||
let resources = dir.join(".local/horizon-client").join(version);
|
||||
resources
|
||||
.join("horizon-resources.json")
|
||||
.exists()
|
||||
.then_some(resources)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Install {
|
||||
pub game: PathBuf,
|
||||
/// Passed as --resources unless the caller gave their own.
|
||||
pub resources: Option<PathBuf>,
|
||||
/// The working directory (the game home for prepared installs).
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub source: &'static str,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Which game to start: an explicit binary, the one beside horizon-ctl, or a
|
||||
/// launcher install.
|
||||
pub fn find(
|
||||
game: Option<&Path>,
|
||||
version: Option<&str>,
|
||||
beside: Option<&Path>,
|
||||
launcher: &Path,
|
||||
) -> Result<Install, String> {
|
||||
let prepared = |dir: &Path, source, version| {
|
||||
let resources = prepared_resources(dir);
|
||||
Install {
|
||||
game: dir.join(CLIENT),
|
||||
cwd: resources.as_ref().map(|_| game_home()),
|
||||
resources,
|
||||
source,
|
||||
version,
|
||||
}
|
||||
};
|
||||
if let Some(game) = game {
|
||||
if !game.is_file() {
|
||||
return Err(format!("{} is not a file", game.display()));
|
||||
}
|
||||
let dir = game.parent().unwrap_or(Path::new("."));
|
||||
let mut install = prepared(dir, "--game", None);
|
||||
install.game = game.to_owned();
|
||||
return Ok(install);
|
||||
}
|
||||
let versions = launcher.join("versions");
|
||||
if version.is_none()
|
||||
&& let Some(dir) = beside
|
||||
&& dir.join(CLIENT).is_file()
|
||||
{
|
||||
return Ok(prepared(dir, "beside horizon-ctl", None));
|
||||
}
|
||||
let tags = installed(&versions);
|
||||
let tag = match version {
|
||||
Some(v) => {
|
||||
let want = if v.starts_with('v') { v.to_owned() } else { format!("v{v}") };
|
||||
tags.iter()
|
||||
.find(|t| **t == want || **t == v)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("version {v} is not installed in {} (installed: {tags:?})", versions.display()))?
|
||||
}
|
||||
None => tags.first().cloned().ok_or_else(|| {
|
||||
format!(
|
||||
"no Horizon found: no horizon-client beside horizon-ctl and no version installed by the launcher in {}. Install one with the launcher (horizon-launcher install latest) or pass --game PATH.",
|
||||
versions.display()
|
||||
)
|
||||
})?,
|
||||
};
|
||||
let dir = versions.join(&tag);
|
||||
let install = prepared(&dir, "launcher", Some(tag.clone()));
|
||||
if install.resources.is_none() {
|
||||
return Err(format!(
|
||||
"{tag} is installed but not prepared; prepare it in the launcher first (horizon-launcher prepare {tag})"
|
||||
));
|
||||
}
|
||||
Ok(install)
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
pub game: Option<PathBuf>,
|
||||
pub version: Option<String>,
|
||||
pub wait: bool,
|
||||
pub game_args: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse(args: &[String]) -> Result<Options, String> {
|
||||
let mut o = Options {
|
||||
game: None,
|
||||
version: None,
|
||||
wait: false,
|
||||
game_args: Vec::new(),
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let value = |i: usize| {
|
||||
args.get(i + 1)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("{} needs a value", args[i]))
|
||||
};
|
||||
match args[i].as_str() {
|
||||
"--game" => {
|
||||
o.game = Some(value(i)?.into());
|
||||
i += 1;
|
||||
}
|
||||
"--version" => {
|
||||
o.version = Some(value(i)?);
|
||||
i += 1;
|
||||
}
|
||||
"--wait" | "--wait-ready" => o.wait = true,
|
||||
other => o.game_args.push(other.to_owned()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// Start the game and wait for its control endpoint; Ok(reply) or Err(reply).
|
||||
pub fn launch(socket: &Path, args: &[String]) -> Result<Value, Value> {
|
||||
let o = parse(args).map_err(|e| json!({ "error": e, "kind": "usage" }))?;
|
||||
if horizon_client::control::connect(socket).is_ok() {
|
||||
return Err(json!({
|
||||
"error": format!("a game is already listening on {}; quit it first or pass another --socket", socket.display()),
|
||||
"kind": "connection",
|
||||
}));
|
||||
}
|
||||
let exe = std::env::current_exe().ok();
|
||||
let beside = exe.as_deref().and_then(Path::parent);
|
||||
let install = find(
|
||||
o.game.as_deref(),
|
||||
o.version.as_deref(),
|
||||
beside,
|
||||
&launcher_home(),
|
||||
)
|
||||
.map_err(|e| json!({ "error": e, "kind": "launch" }))?;
|
||||
let log = socket.with_extension("log");
|
||||
let file = std::fs::File::create(&log)
|
||||
.map_err(|e| json!({ "error": format!("{}: {e}", log.display()), "kind": "launch" }))?;
|
||||
let mut cmd = Command::new(&install.game);
|
||||
let gave_resources = o.game_args.iter().any(|a| a == "--resources");
|
||||
if let Some(resources) = install.resources.as_ref().filter(|_| !gave_resources) {
|
||||
cmd.arg("--resources").arg(resources);
|
||||
}
|
||||
if let Some(cwd) = &install.cwd {
|
||||
let _ = std::fs::create_dir_all(cwd);
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let stderr = file
|
||||
.try_clone()
|
||||
.map_err(|e| json!({ "error": e.to_string(), "kind": "launch" }))?;
|
||||
let mut child = cmd
|
||||
.args(&o.game_args)
|
||||
.arg("--control-socket")
|
||||
.arg(socket)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(file)
|
||||
.stderr(stderr)
|
||||
.spawn()
|
||||
.map_err(
|
||||
|e| json!({ "error": format!("{}: {e}", install.game.display()), "kind": "launch" }),
|
||||
)?;
|
||||
let deadline = Instant::now() + Duration::from_secs(300);
|
||||
let info = json!({
|
||||
"pid": child.id(),
|
||||
"socket": socket,
|
||||
"log": log,
|
||||
"game": install.game,
|
||||
"source": install.source,
|
||||
"version": install.version,
|
||||
"resources": install.resources,
|
||||
"home": install.cwd,
|
||||
});
|
||||
while Instant::now() < deadline {
|
||||
if horizon_client::control::connect(socket).is_ok() {
|
||||
let mut reply = json!({ "ok": true });
|
||||
reply
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(info.as_object().unwrap().clone());
|
||||
return Ok(reply);
|
||||
}
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
let tail = std::fs::read_to_string(&log).unwrap_or_default();
|
||||
let tail: Vec<&str> = tail.lines().rev().take(15).collect();
|
||||
return Err(json!({
|
||||
"error": format!("the game exited ({status}) before opening the control socket"),
|
||||
"kind": "launch",
|
||||
"log_tail": tail.into_iter().rev().collect::<Vec<_>>(),
|
||||
"log": log,
|
||||
}));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(
|
||||
json!({ "error": "control socket did not open within 300 seconds", "kind": "launch", "log": log }),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-ctl-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
fn fake_version(versions: &Path, tag: &str, prepared: bool) -> PathBuf {
|
||||
let dir = versions.join(tag);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(CLIENT), "").unwrap();
|
||||
std::fs::write(dir.join(".installed"), "").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("research-lock.json"),
|
||||
r#"{"minecraft":{"version":"26.3"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
if prepared {
|
||||
let res = dir.join(".local/horizon-client/26.3");
|
||||
std::fs::create_dir_all(&res).unwrap();
|
||||
std::fs::write(res.join("horizon-resources.json"), "{}").unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_sort_newest_first() {
|
||||
let home = temp("sort");
|
||||
let versions = home.join("versions");
|
||||
for tag in ["v2026.9.30", "v2026.10.13", "v2026.10.13.1", "v2026.10.2"] {
|
||||
fake_version(&versions, tag, true);
|
||||
}
|
||||
assert_eq!(
|
||||
installed(&versions),
|
||||
["v2026.10.13.1", "v2026.10.13", "v2026.10.2", "v2026.9.30"]
|
||||
);
|
||||
let install = find(None, None, None, &home).unwrap();
|
||||
assert_eq!(install.version.as_deref(), Some("v2026.10.13.1"));
|
||||
assert!(
|
||||
install
|
||||
.resources
|
||||
.unwrap()
|
||||
.ends_with(".local/horizon-client/26.3")
|
||||
);
|
||||
assert_eq!(install.cwd, Some(game_home()));
|
||||
let picked = find(None, Some("2026.10.2"), None, &home).unwrap();
|
||||
assert_eq!(picked.version.as_deref(), Some("v2026.10.2"));
|
||||
assert!(find(None, Some("v1.0"), None, &home).is_err());
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_beside_ctl_wins_and_dev_builds_keep_the_directory() {
|
||||
let home = temp("beside");
|
||||
fake_version(&home.join("versions"), "v2026.10.13", true);
|
||||
let build = temp("build");
|
||||
std::fs::write(build.join(CLIENT), "").unwrap();
|
||||
let install = find(None, None, Some(&build), &home).unwrap();
|
||||
assert_eq!(install.source, "beside horizon-ctl");
|
||||
assert_eq!((install.resources, install.cwd), (None, None));
|
||||
// --version always means a launcher install.
|
||||
assert_eq!(
|
||||
find(None, Some("v2026.10.13"), Some(&build), &home)
|
||||
.unwrap()
|
||||
.source,
|
||||
"launcher"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
let _ = std::fs::remove_dir_all(build);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unprepared_or_missing_installs_explain_themselves() {
|
||||
let home = temp("unprepared");
|
||||
assert!(
|
||||
find(None, None, None, &home)
|
||||
.unwrap_err()
|
||||
.contains("no Horizon found")
|
||||
);
|
||||
fake_version(&home.join("versions"), "v2026.10.13", false);
|
||||
assert!(
|
||||
find(None, None, None, &home)
|
||||
.unwrap_err()
|
||||
.contains("not prepared")
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_options_pass_the_rest_to_the_game() {
|
||||
let args: Vec<String> = ["--world", "A", "--version", "v1", "--wait", "--seed", "3"]
|
||||
.map(String::from)
|
||||
.to_vec();
|
||||
let o = parse(&args).unwrap();
|
||||
assert_eq!(o.version.as_deref(), Some("v1"));
|
||||
assert!(o.wait);
|
||||
assert_eq!(o.game_args, ["--world", "A", "--seed", "3"]);
|
||||
assert!(parse(&["--game".to_owned()]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! horizon-ctl: play and script a running Horizon client through its control
|
||||
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
|
||||
#![forbid(unsafe_code)]
|
||||
mod commands;
|
||||
mod launch;
|
||||
mod mcp;
|
||||
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::ExitCode,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use horizon_client::control::Stream;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Exit statuses.
|
||||
const GAME_ERROR: u8 = 1;
|
||||
const USAGE: u8 = 2;
|
||||
const UNREACHABLE: u8 = 3;
|
||||
|
||||
/// A failure, with the exit status it maps to.
|
||||
#[derive(Debug)]
|
||||
pub struct Failure {
|
||||
pub reply: Value,
|
||||
pub status: u8,
|
||||
}
|
||||
impl Failure {
|
||||
fn usage(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "usage" }),
|
||||
status: USAGE,
|
||||
}
|
||||
}
|
||||
fn unreachable(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "connection" }),
|
||||
status: UNREACHABLE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
writer: Stream,
|
||||
reader: BufReader<Stream>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn connect(path: &Path, timeout: Option<Duration>) -> Result<Self, Failure> {
|
||||
let stream = horizon_client::control::connect(path).map_err(|e| {
|
||||
Failure::unreachable(format!(
|
||||
"cannot connect to {} ({e}); is the game running with --control? Start one with: horizon-ctl launch --world NAME",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
stream
|
||||
.set_read_timeout(timeout)
|
||||
.map_err(|e| Failure::unreachable(e.to_string()))?;
|
||||
let reader = BufReader::new(
|
||||
stream
|
||||
.try_clone()
|
||||
.map_err(|e| Failure::unreachable(e.to_string()))?,
|
||||
);
|
||||
Ok(Self {
|
||||
writer: stream,
|
||||
reader,
|
||||
})
|
||||
}
|
||||
/// Send one line and read its JSON reply.
|
||||
fn send(&mut self, line: &str) -> Result<Value, Failure> {
|
||||
writeln!(self.writer, "{line}").map_err(|e| Failure::unreachable(e.to_string()))?;
|
||||
let mut reply = String::new();
|
||||
let n = self.reader.read_line(&mut reply).map_err(|e| {
|
||||
Failure::unreachable(
|
||||
if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||
) {
|
||||
"timed out waiting for the game's reply (--timeout)".to_owned()
|
||||
} else {
|
||||
e.to_string()
|
||||
},
|
||||
)
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Err(Failure::unreachable("the game closed the connection"));
|
||||
}
|
||||
serde_json::from_str(reply.trim_end()).map_err(|_| Failure {
|
||||
reply: json!({ "error": "the game sent a reply that is not JSON", "reply": reply.trim_end() }),
|
||||
status: GAME_ERROR,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Global options and the connection, opened on first use.
|
||||
pub struct Session {
|
||||
pub socket: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
client: Option<Client>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(socket: PathBuf, timeout: Option<Duration>) -> Self {
|
||||
Self {
|
||||
socket,
|
||||
timeout,
|
||||
client: None,
|
||||
}
|
||||
}
|
||||
fn client(&mut self) -> Result<&mut Client, Failure> {
|
||||
if self.client.is_none() {
|
||||
self.client = Some(Client::connect(&self.socket, self.timeout)?);
|
||||
}
|
||||
Ok(self.client.as_mut().unwrap())
|
||||
}
|
||||
/// Send a line to the game; a reply with "error" is a failure.
|
||||
pub fn game(&mut self, line: &str) -> Result<Value, Failure> {
|
||||
let result = self.client()?.send(line);
|
||||
if result.as_ref().is_err_and(|f| f.status == UNREACHABLE) {
|
||||
// Reconnect next time (the game may have restarted).
|
||||
self.client = None;
|
||||
}
|
||||
let reply = result?;
|
||||
if reply.get("error").is_some() {
|
||||
return Err(Failure {
|
||||
reply,
|
||||
status: GAME_ERROR,
|
||||
});
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// Run one command (CLI words); Ok(reply) or a failure.
|
||||
pub fn run(&mut self, words: &[String]) -> Result<Value, Failure> {
|
||||
let Some(name) = words.first().map(String::as_str) else {
|
||||
return Err(Failure::usage("no command; try horizon-ctl help"));
|
||||
};
|
||||
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
|
||||
return Err(Failure::usage(
|
||||
"use horizon-ctl COMMAND --help outside scripts",
|
||||
));
|
||||
}
|
||||
let number = |i: usize, what: &str| -> Result<f64, Failure> {
|
||||
words
|
||||
.get(i)
|
||||
.ok_or_else(|| Failure::usage(format!("missing {what}; usage: {}", usage(name))))?
|
||||
.parse::<f64>()
|
||||
.map_err(|_| {
|
||||
Failure::usage(format!("{what} must be a number; usage: {}", usage(name)))
|
||||
})
|
||||
};
|
||||
match name {
|
||||
"sleep" => {
|
||||
thread::sleep(Duration::from_secs_f64(number(1, "SECONDS")?.max(0.0)));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"launch" => {
|
||||
let socket = self.socket.clone();
|
||||
let wait = words.iter().any(|w| w == "--wait" || w == "--wait-ready");
|
||||
let reply = launch::launch(&socket, &words[1..]).map_err(|reply| Failure {
|
||||
status: if reply["kind"] == "usage" {
|
||||
USAGE
|
||||
} else {
|
||||
UNREACHABLE
|
||||
},
|
||||
reply,
|
||||
})?;
|
||||
if !wait {
|
||||
return Ok(reply);
|
||||
}
|
||||
let mut ready = self.wait_ready(300.0)?;
|
||||
ready["launch"] = reply;
|
||||
Ok(ready)
|
||||
}
|
||||
"hold" => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
|
||||
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
|
||||
};
|
||||
self.game(&format!("key {key} down"))?;
|
||||
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
|
||||
self.game(&format!("key {key} up"))
|
||||
}
|
||||
"wait-ready" => {
|
||||
let limit = if words.len() > 1 {
|
||||
number(1, "SECONDS")?
|
||||
} else {
|
||||
120.0
|
||||
};
|
||||
self.wait_ready(limit)
|
||||
}
|
||||
"help" if words.get(1).is_some_and(|w| w == "--game") => self.game("help"),
|
||||
_ => {
|
||||
let mut words = words.to_vec();
|
||||
if let Some(cmd) = commands::find(name) {
|
||||
if cmd.local {
|
||||
return Err(Failure::usage(format!(
|
||||
"{name} runs only as horizon-ctl {name}, not inside a script"
|
||||
)));
|
||||
}
|
||||
if let Some(wire) = cmd.wire {
|
||||
words.splice(0..1, wire.split(' ').map(str::to_owned));
|
||||
}
|
||||
}
|
||||
absolute_paths(&mut words);
|
||||
self.game(&words.join(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_ready(&mut self, limit: f64) -> Result<Value, Failure> {
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(limit.max(0.0));
|
||||
loop {
|
||||
let status = self.game("status")?;
|
||||
if !status["player"].is_null() && status["pending_chunks"] == 0 {
|
||||
return Ok(status);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err(Failure {
|
||||
reply: json!({ "error": format!("not ready after {limit} seconds"), "status": status }),
|
||||
status: GAME_ERROR,
|
||||
});
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn usage(name: &str) -> String {
|
||||
commands::find(name).map_or_else(|| name.to_owned(), |c| format!("horizon-ctl {}", c.usage))
|
||||
}
|
||||
|
||||
/// Screenshot paths are written by the game process: make them absolute here.
|
||||
fn absolute_paths(words: &mut [String]) {
|
||||
if words.first().map(String::as_str) == Some("screenshot")
|
||||
&& let Some(path) = words.get_mut(1)
|
||||
&& let Ok(cwd) = std::env::current_dir()
|
||||
{
|
||||
*path = cwd.join(&*path).display().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a script line like a shell: quotes keep spaces, backslash escapes.
|
||||
pub fn split_line(line: &str) -> Result<Vec<String>, String> {
|
||||
let mut words = Vec::new();
|
||||
let mut word = String::new();
|
||||
let mut in_word = false;
|
||||
let mut quote: Option<char> = None;
|
||||
let mut chars = line.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match (quote, c) {
|
||||
(Some(q), c) if c == q => quote = None,
|
||||
(Some('"'), '\\') => word.push(chars.next().unwrap_or('\\')),
|
||||
(Some(_), c) => word.push(c),
|
||||
(None, '"' | '\'') => {
|
||||
quote = Some(c);
|
||||
in_word = true;
|
||||
}
|
||||
(None, '\\') => {
|
||||
word.push(chars.next().unwrap_or('\\'));
|
||||
in_word = true;
|
||||
}
|
||||
(None, '#') if !in_word => break,
|
||||
(None, c) if c.is_whitespace() => {
|
||||
if in_word {
|
||||
words.push(std::mem::take(&mut word));
|
||||
in_word = false;
|
||||
}
|
||||
}
|
||||
(None, c) => {
|
||||
word.push(c);
|
||||
in_word = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if quote.is_some() {
|
||||
return Err("unterminated quote".into());
|
||||
}
|
||||
if in_word {
|
||||
words.push(word);
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
fn print(value: &Value, pretty: bool) {
|
||||
let text = if pretty {
|
||||
serde_json::to_string_pretty(value).unwrap_or_default()
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
println!("{text}");
|
||||
}
|
||||
|
||||
/// Run a script; the exit status of the first failure (or the last one with --keep-going).
|
||||
fn script(session: &mut Session, reader: impl BufRead, keep_going: bool, pretty: bool) -> u8 {
|
||||
let mut status = 0;
|
||||
for (n, line) in reader.lines().enumerate() {
|
||||
let Ok(line) = line else { break };
|
||||
let words = match split_line(&line) {
|
||||
Ok(w) if w.is_empty() => continue,
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
print(
|
||||
&json!({ "error": e, "kind": "usage", "line": n + 1 }),
|
||||
pretty,
|
||||
);
|
||||
status = USAGE;
|
||||
if keep_going {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
match session.run(&words) {
|
||||
Ok(reply) => print(&reply, pretty),
|
||||
Err(mut f) => {
|
||||
f.reply["line"] = json!(n + 1);
|
||||
print(&f.reply, pretty);
|
||||
status = f.status;
|
||||
if !keep_going || f.status == UNREACHABLE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut socket = horizon_client::control::default_path();
|
||||
let mut timeout = None;
|
||||
let mut pretty = false;
|
||||
// Global options before the command.
|
||||
loop {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("--socket") if args.len() >= 2 => {
|
||||
socket = PathBuf::from(args.remove(1));
|
||||
args.remove(0);
|
||||
}
|
||||
Some("--timeout") if args.len() >= 2 => {
|
||||
let Ok(s) = args[1].parse::<f64>() else {
|
||||
print(
|
||||
&json!({ "error": "--timeout takes seconds", "kind": "usage" }),
|
||||
false,
|
||||
);
|
||||
return ExitCode::from(USAGE);
|
||||
};
|
||||
timeout = Some(Duration::from_secs_f64(s.max(0.001)));
|
||||
args.drain(0..2);
|
||||
}
|
||||
Some("--pretty") => {
|
||||
pretty = true;
|
||||
args.remove(0);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let mut session = Session::new(socket, timeout);
|
||||
let name = args.first().cloned();
|
||||
let status = match name.as_deref() {
|
||||
None | Some("-h" | "--help") => {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
|
||||
None => {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some(c) => match commands::find(c) {
|
||||
Some(cmd) => {
|
||||
print!("{}", commands::detail(cmd));
|
||||
0
|
||||
}
|
||||
None => {
|
||||
print(
|
||||
&json!({ "error": format!("unknown command {c}; horizon-ctl help lists them, horizon-ctl help --game lists the game's own"), "kind": "usage" }),
|
||||
pretty,
|
||||
);
|
||||
USAGE
|
||||
}
|
||||
},
|
||||
},
|
||||
Some(c) if args.len() == 2 && matches!(args[1].as_str(), "--help" | "-h") => {
|
||||
match commands::find(c) {
|
||||
Some(cmd) => {
|
||||
print!("{}", commands::detail(cmd));
|
||||
0
|
||||
}
|
||||
None => {
|
||||
print(
|
||||
&json!({ "error": format!("no help for {c}; horizon-ctl help --game lists the game's own commands"), "kind": "usage" }),
|
||||
pretty,
|
||||
);
|
||||
USAGE
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("mcp") => match mcp::serve(&mut session) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("horizon-ctl mcp: {e}");
|
||||
UNREACHABLE
|
||||
}
|
||||
},
|
||||
// The original form: stdin, keep going after errors.
|
||||
Some("-") => script(&mut session, std::io::stdin().lock(), true, pretty),
|
||||
Some("script") => {
|
||||
let keep_going = args.iter().any(|a| a == "--keep-going");
|
||||
match args.iter().skip(1).find(|a| !a.starts_with("--")) {
|
||||
Some(file) if file != "-" => match std::fs::File::open(file) {
|
||||
Ok(f) => script(&mut session, BufReader::new(f), keep_going, pretty),
|
||||
Err(e) => {
|
||||
print(
|
||||
&json!({ "error": format!("{file}: {e}"), "kind": "usage" }),
|
||||
pretty,
|
||||
);
|
||||
USAGE
|
||||
}
|
||||
},
|
||||
_ => script(&mut session, std::io::stdin().lock(), keep_going, pretty),
|
||||
}
|
||||
}
|
||||
Some(_) => match session.run(&args) {
|
||||
Ok(reply) => {
|
||||
print(&reply, pretty);
|
||||
0
|
||||
}
|
||||
Err(f) => {
|
||||
print(&f.reply, pretty);
|
||||
f.status
|
||||
}
|
||||
},
|
||||
};
|
||||
ExitCode::from(status)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn script_lines_split_like_a_shell() {
|
||||
assert_eq!(
|
||||
split_line("goto 1 2 3 --near 2").unwrap(),
|
||||
["goto", "1", "2", "3", "--near", "2"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_line("click-button \"Create New World\"").unwrap(),
|
||||
["click-button", "Create New World"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_line("type 'it''s' # comment").unwrap(),
|
||||
["type", "its"]
|
||||
);
|
||||
assert_eq!(split_line("chat a\\ b").unwrap(), ["chat", "a b"]);
|
||||
assert_eq!(split_line("chat issue#4").unwrap(), ["chat", "issue#4"]);
|
||||
assert!(split_line(" # only a comment").unwrap().is_empty());
|
||||
assert!(split_line("type \"open").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_games_fail_with_status_3() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
let f = s.run(&["state".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, UNREACHABLE);
|
||||
assert_eq!(f.reply["kind"], "connection");
|
||||
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_paths_become_absolute() {
|
||||
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
|
||||
absolute_paths(&mut words);
|
||||
assert!(Path::new(&words[1]).is_absolute());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! `horizon-ctl mcp`: the Model Context Protocol over stdin and stdout
|
||||
//! (JSON-RPC 2.0, one message per line, like the launcher's `mcp`), so AI
|
||||
//! agents can play Horizon. Each tool is a horizon-ctl command from the
|
||||
//! shared table, run through the same `Session` as the CLI.
|
||||
use crate::{Session, commands};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
const PROTOCOL: &str = "2025-06-18";
|
||||
|
||||
fn tools() -> Vec<Value> {
|
||||
commands::COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.mcp)
|
||||
.map(|c| {
|
||||
let mut description = format!("{}. Usage: horizon-ctl {}", capital(c.summary), c.usage);
|
||||
if !c.details.is_empty() {
|
||||
description.push_str(". ");
|
||||
description.push_str(c.details);
|
||||
}
|
||||
let mut schema = commands::schema(c);
|
||||
if c.name == "screenshot" {
|
||||
schema = json!({ "type": "object", "properties": {} });
|
||||
description = "Capture the game window and return it as a PNG image.".into();
|
||||
}
|
||||
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn capital(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
c.next()
|
||||
.map(|f| f.to_uppercase().chain(c).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648) for image content.
|
||||
pub fn base64(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = (u32::from(chunk[0]) << 16)
|
||||
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
|
||||
| u32::from(*chunk.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
if i <= chunk.len() {
|
||||
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// One tool call: (content blocks, is_error).
|
||||
fn call(&mut self, name: &str, args: &Value) -> (Vec<Value>, bool) {
|
||||
let text = |v: &Value| json!({ "type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default() });
|
||||
let Some(cmd) = commands::COMMANDS
|
||||
.iter()
|
||||
.find(|c| c.mcp && commands::tool_name(c) == name)
|
||||
else {
|
||||
return (
|
||||
vec![text(&json!({ "error": format!("unknown tool {name}") }))],
|
||||
true,
|
||||
);
|
||||
};
|
||||
if cmd.name == "screenshot" {
|
||||
let path = std::env::temp_dir().join(format!("horizon-ctl-{}.png", std::process::id()));
|
||||
let result = self
|
||||
.run(&["screenshot".to_owned(), path.display().to_string()])
|
||||
.and_then(|reply| {
|
||||
std::fs::read(&path)
|
||||
.map(|png| (reply, png))
|
||||
.map_err(|e| crate::Failure {
|
||||
reply: json!({ "error": format!("{}: {e}", path.display()) }),
|
||||
status: 1,
|
||||
})
|
||||
});
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return match result {
|
||||
Ok((reply, png)) => (
|
||||
vec![
|
||||
json!({ "type": "image", "data": base64(&png), "mimeType": "image/png" }),
|
||||
text(&reply),
|
||||
],
|
||||
false,
|
||||
),
|
||||
Err(f) => (vec![text(&f.reply)], true),
|
||||
};
|
||||
}
|
||||
let mut words = match commands::line_for(cmd, args) {
|
||||
Ok(w) => w,
|
||||
Err(e) => return (vec![text(&json!({ "error": e, "kind": "usage" }))], true),
|
||||
};
|
||||
if cmd.name == "launch" {
|
||||
// An agent wants a world it can play once the call returns.
|
||||
if args.get("wait").and_then(Value::as_bool) != Some(false)
|
||||
&& !words.contains(&"--wait".to_owned())
|
||||
{
|
||||
words.push("--wait".into());
|
||||
}
|
||||
words.push("--no-pause".into());
|
||||
}
|
||||
match self.run(&words) {
|
||||
Ok(reply) => (vec![text(&reply)], false),
|
||||
Err(f) => (vec![text(&f.reply)], true),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": env!("CARGO_PKG_VERSION") },
|
||||
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
|
||||
})),
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({ "tools": tools() })),
|
||||
"tools/call" => {
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or((-32602, "name is required".to_string()))?;
|
||||
if !tools().iter().any(|t| t["name"] == name) {
|
||||
return Err((-32602, format!("Unknown tool {name}")));
|
||||
}
|
||||
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
let (content, error) = self.call(name, &args);
|
||||
Ok(json!({ "content": content, "isError": error }))
|
||||
}
|
||||
_ => Err((-32601, format!("Method not found: {method}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serve(session: &mut Session) -> Result<(), String> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut stdout = std::io::stdout();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Err(e) => Some(
|
||||
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
|
||||
),
|
||||
Ok(msg) => {
|
||||
let method = msg
|
||||
.get("method")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// Notifications (no id) get no reply.
|
||||
msg.get("id").cloned().map(|id| match session.handle(method, ¶ms) {
|
||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
writeln!(stdout, "{reply}")
|
||||
.and_then(|_| stdout.flush())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_matches_rfc_4648() {
|
||||
assert_eq!(base64(b""), "");
|
||||
assert_eq!(base64(b"f"), "Zg==");
|
||||
assert_eq!(base64(b"fo"), "Zm8=");
|
||||
assert_eq!(base64(b"foo"), "Zm9v");
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_have_schemas_and_unique_names() {
|
||||
let list = tools();
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for t in &list {
|
||||
assert!(names.insert(t["name"].as_str().unwrap().to_owned()));
|
||||
assert_eq!(t["inputSchema"]["type"], "object");
|
||||
}
|
||||
for want in [
|
||||
"launch",
|
||||
"state",
|
||||
"goto",
|
||||
"break_block",
|
||||
"place",
|
||||
"click_slot",
|
||||
"click_button",
|
||||
"screenshot",
|
||||
"inventory",
|
||||
] {
|
||||
let want = if want == "break_block" { "break" } else { want };
|
||||
assert!(names.contains(want), "{want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_basics() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
|
||||
let init = s
|
||||
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
|
||||
.unwrap();
|
||||
assert_eq!(init["protocolVersion"], "2025-03-26");
|
||||
assert!(s.handle("nope", &json!({})).is_err());
|
||||
let r = s
|
||||
.handle(
|
||||
"tools/call",
|
||||
&json!({ "name": "goto", "arguments": { "x": 1 } }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["isError"], true);
|
||||
assert!(
|
||||
r["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("y is required")
|
||||
);
|
||||
// No game: a connection error, reported as a tool error.
|
||||
let r = s
|
||||
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
|
||||
.unwrap();
|
||||
assert_eq!(r["isError"], true);
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,7 @@ pub fn draw_part(
|
||||
let color = pack_color(tint, 255, diffuse(normal, lights));
|
||||
for (v, position) in poly.iter().zip(pos) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: position.to_array(),
|
||||
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
|
||||
layer: 0,
|
||||
@@ -637,6 +638,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, 0.0))
|
||||
@@ -1587,6 +1589,7 @@ impl BeamPart {
|
||||
(at(b, self.end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
@@ -1656,6 +1659,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
|
||||
for &d in faces {
|
||||
for c in PORTAL_FACES[d as usize] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(Vec3::from(c)).to_array(),
|
||||
uv: [0.0; 2],
|
||||
layer,
|
||||
|
||||
@@ -54,6 +54,8 @@ pub struct StateFacts {
|
||||
pub waterlogged: bool,
|
||||
/// `hasOffsetFunction`: the model is jittered per column.
|
||||
pub random_offset: bool,
|
||||
/// Whether the outline, not just the model, follows BlockState.getOffset.
|
||||
pub outline_offset: bool,
|
||||
/// `BlockState.isSolid()` (legacy solidity; fluid surfaces read it).
|
||||
pub legacy_solid: bool,
|
||||
/// `isFaceSturdy(.., SupportType.FULL)` by Direction ordinal.
|
||||
@@ -62,8 +64,8 @@ pub struct StateFacts {
|
||||
pub classes: Arc<[Arc<str>]>,
|
||||
/// `getShape` (the outline used for picking) as boxes in block space,
|
||||
/// without the random offset; and its edges as LevelRenderer draws them.
|
||||
pub outline: Arc<[[f32; 6]]>,
|
||||
pub outline_edges: Arc<[[f32; 6]]>,
|
||||
pub outline: Arc<[[f64; 6]]>,
|
||||
pub outline_edges: Arc<[[f64; 6]]>,
|
||||
/// `getMapColor(..).id` (0 = NONE).
|
||||
pub map_color: u8,
|
||||
}
|
||||
@@ -78,11 +80,11 @@ pub const MAP_COLORS: [u32; 62] = [
|
||||
14200723, 8365974,
|
||||
];
|
||||
/// The 12 edges of the unit cube (outline data predating the export).
|
||||
fn cube_edges() -> Vec<[f32; 6]> {
|
||||
fn cube_edges() -> Vec<[f64; 6]> {
|
||||
let mut edges = Vec::new();
|
||||
for a in 0..3 {
|
||||
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
|
||||
let mut from = [0.0f32; 3];
|
||||
let mut from = [0.0f64; 3];
|
||||
let (b, c) = ((a + 1) % 3, (a + 2) % 3);
|
||||
from[b] = u;
|
||||
from[c] = v;
|
||||
@@ -147,8 +149,8 @@ impl BlockFacts {
|
||||
.or_insert_with(|| Arc::from(s))
|
||||
.clone()
|
||||
};
|
||||
let mut shapes: HashMap<String, Arc<[[f32; 6]]>> = HashMap::new();
|
||||
let mut shape = |v: &Value, fallback: &dyn Fn() -> Vec<[f32; 6]>| -> Arc<[[f32; 6]]> {
|
||||
let mut shapes: HashMap<String, Arc<[[f64; 6]]>> = HashMap::new();
|
||||
let mut shape = |v: &Value, fallback: &dyn Fn() -> Vec<[f64; 6]>| -> Arc<[[f64; 6]]> {
|
||||
let Some(list) = v.as_array() else {
|
||||
return fallback().into();
|
||||
};
|
||||
@@ -158,8 +160,7 @@ impl BlockFacts {
|
||||
list.iter()
|
||||
.filter_map(|b| {
|
||||
let b = b.as_array()?;
|
||||
let n =
|
||||
|i: usize| b.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
let n = |i: usize| b.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
Some([n(0), n(1), n(2), n(3), n(4), n(5)])
|
||||
})
|
||||
.collect()
|
||||
@@ -263,6 +264,9 @@ impl BlockFacts {
|
||||
fluid_falling,
|
||||
waterlogged: prop("waterlogged") == Some("true"),
|
||||
random_offset: flag("offset"),
|
||||
outline_offset: entry["outline_offset"]
|
||||
.as_bool()
|
||||
.unwrap_or_else(|| flag("offset")),
|
||||
legacy_solid: behaviour_states[index][1].as_u64().unwrap_or(0) & 1 != 0,
|
||||
sturdy_full: (behaviour_states[index][0].as_u64().unwrap_or(0) & 63) as u8,
|
||||
classes: classes
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! BlockModelFeatureRenderer breaking quads and SheetedDecalTextureGenerator UVs.
|
||||
use crate::{camera::joml, mesher::Vertex, models::catalog::BakedModel};
|
||||
use horizon_assets::model::Direction;
|
||||
use std::f32::consts::{FRAC_PI_2, PI};
|
||||
|
||||
/// Project in model space: the inverse decal pose cancels the block translation/offset.
|
||||
pub fn decal_uv([x, y, z]: [f32; 3], direction: Direction) -> [f32; 2] {
|
||||
let sy = joml::sin(PI);
|
||||
let cy = joml::cos_from_sin(sy, PI);
|
||||
let sx = joml::sin(-FRAC_PI_2);
|
||||
let cx = joml::cos_from_sin(sx, -FRAC_PI_2);
|
||||
let p = [cy * x + sy * z, y, -sy * x + cy * z];
|
||||
let p = [p[0], cx * p[1] - sx * p[2], sx * p[1] + cx * p[2]];
|
||||
let (angle_x, angle_z) = match direction {
|
||||
Direction::Down => (PI, 0.0),
|
||||
Direction::Up => (0.0, 0.0),
|
||||
Direction::North => (FRAC_PI_2, PI),
|
||||
Direction::South => (FRAC_PI_2, 0.0),
|
||||
Direction::West => (FRAC_PI_2, FRAC_PI_2),
|
||||
Direction::East => (FRAC_PI_2, -FRAC_PI_2),
|
||||
};
|
||||
let p = joml::Quat::rotation_yxz(0.0, angle_x, angle_z).transform(p);
|
||||
[-p[0], -p[1]]
|
||||
}
|
||||
|
||||
pub fn geometry(model: &BakedModel, origin: glam::Vec3, translucent: bool, out: &mut Vec<Vertex>) {
|
||||
// Vanilla visits all six cull groups and then the unculled faces, even beside solid neighbours.
|
||||
for cull in Direction::ALL.map(Some).into_iter().chain([None]) {
|
||||
for face in model.faces.iter().filter(|face| face.quad.cull == cull) {
|
||||
for pos in face.quad.positions {
|
||||
out.push(Vertex {
|
||||
pos: (origin + glam::Vec3::from(pos)).to_array(),
|
||||
uv: decal_uv(pos, face.quad.facing),
|
||||
layer: if translucent { 12 } else { 11 },
|
||||
color: 0xffff_ffff,
|
||||
light: 240 | (240 << 8),
|
||||
normal: [0; 4],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::catalog::BakedFace;
|
||||
use horizon_assets::{bake::ModelQuad, model::Material};
|
||||
fn slab_top() -> BakedModel {
|
||||
BakedModel {
|
||||
ambient_occlusion: true,
|
||||
cube: None,
|
||||
faces: vec![BakedFace {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
quad: ModelQuad {
|
||||
positions: [
|
||||
[0.0, 0.5, 0.0],
|
||||
[0.0, 0.5, 1.0],
|
||||
[1.0, 0.5, 1.0],
|
||||
[1.0, 0.5, 0.0],
|
||||
],
|
||||
uv: [[0.0; 2]; 4],
|
||||
facing: Direction::Up,
|
||||
cull: None,
|
||||
material: Material {
|
||||
sprite: "block/stone".into(),
|
||||
force_translucent: false,
|
||||
},
|
||||
tint_index: -1,
|
||||
shade_direction_override: None,
|
||||
light_emission: 0,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn slab_overlay_follows_baked_top_not_unit_cube() {
|
||||
let model = slab_top();
|
||||
let mut vertices = Vec::new();
|
||||
geometry(&model, glam::Vec3::new(2.0, 3.0, 4.0), false, &mut vertices);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
for (v, p) in vertices.iter().zip(model.faces[0].quad.positions) {
|
||||
assert_eq!(v.pos, [p[0] + 2.0, p[1] + 3.0, p[2] + 4.0]);
|
||||
assert_eq!(v.color, 0xffff_ffff);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn small_faces_use_projected_not_stretched_uvs() {
|
||||
let a = decal_uv([0.0, 0.0, 0.0], Direction::North);
|
||||
let b = decal_uv([0.0, 0.5, 0.0], Direction::North);
|
||||
assert!(((b[1] - a[1]).abs() - 0.5).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn translucent_overlay_has_its_own_pass() {
|
||||
let mut vertices = Vec::new();
|
||||
geometry(&slab_top(), glam::Vec3::ZERO, true, &mut vertices);
|
||||
assert!(vertices.iter().all(|v| v.layer == 12));
|
||||
}
|
||||
#[test]
|
||||
fn empty_model_has_no_cube_overlay() {
|
||||
let mut model = slab_top();
|
||||
model.faces.clear();
|
||||
let mut vertices = Vec::new();
|
||||
geometry(&model, glam::Vec3::ZERO, false, &mut vertices);
|
||||
assert!(vertices.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,13 @@ pub mod joml {
|
||||
}
|
||||
use joml::{Mat4, Quat};
|
||||
|
||||
/// Camera-relative world view used by terrain, sky and the first-person hand.
|
||||
pub fn view_matrix(yaw: f32, pitch: f32) -> glam::Mat4 {
|
||||
// Vanilla uses the conjugate of Camera.rotation, not a world-up look-at.
|
||||
let q = Quat::rotation_yxz(PI - yaw * DEG_TO_RAD, -pitch * DEG_TO_RAD, 0.0);
|
||||
glam::Mat4::from_quat(glam::Quat::from_xyzw(-q.x, -q.y, -q.z, q.w))
|
||||
}
|
||||
|
||||
/// `CameraType`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CameraType {
|
||||
@@ -583,6 +590,12 @@ impl Camera {
|
||||
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
|
||||
}
|
||||
}
|
||||
/// `Camera.update`: the far plane covers four times the render distance and
|
||||
/// the whole cloud range (`depthFar`).
|
||||
pub fn depth_far(render_distance_chunks: i32, cloud_range_chunks: i32) -> f32 {
|
||||
(render_distance_chunks as f32 * 16.0 * 4.0).max((cloud_range_chunks * 16) as f32)
|
||||
}
|
||||
|
||||
/// `Projection.getMatrix` for a perspective set up by `setupPerspective`:
|
||||
/// JOML's `Matrix4f.setPerspective(fov * DEG_TO_RAD, width / height, near,
|
||||
/// far, zZeroToOne)`, column-major. Vanilla passes its planes reversed
|
||||
@@ -761,8 +774,36 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn depth_far_covers_the_cloud_range() {
|
||||
// renderDistance 2 with the default Cloud Distance (128 chunks).
|
||||
assert_eq!(super::depth_far(2, 128), 2048.0);
|
||||
assert_eq!(super::depth_far(32, 2), 2048.0);
|
||||
assert_eq!(super::depth_far(12, 12), 768.0);
|
||||
}
|
||||
use super::*;
|
||||
#[test]
|
||||
fn view_keeps_yaw_at_vertical_pitch_limits() {
|
||||
for yaw in [0.0, 45.0, 90.0, 180.0, -90.0, 360.0] {
|
||||
for pitch in [-90.0, -89.999, 0.0, 89.999, 90.0] {
|
||||
let view = view_matrix(yaw, pitch);
|
||||
let mut camera = Camera::default();
|
||||
camera.set_rotation(yaw, pitch);
|
||||
for (world, expected) in [
|
||||
(camera.forwards, -glam::Vec3::Z),
|
||||
(camera.up, glam::Vec3::Y),
|
||||
(camera.left, -glam::Vec3::X),
|
||||
] {
|
||||
let got = view.transform_vector3(glam::Vec3::from(world));
|
||||
assert!(
|
||||
(got - expected).length() < 1e-5,
|
||||
"yaw={yaw}, pitch={pitch}: {got:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn camera_eases_eye_height_and_fov() {
|
||||
let mut camera = Camera::default();
|
||||
camera.tick(Some(1.62), 1.0);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Fancy cloud face selection (`CloudRenderer.buildExtrudedCell`).
|
||||
/// Direction order is DOWN, UP, NORTH, SOUTH, WEST, EAST; inward faces use bits 6..12.
|
||||
pub fn face_mask(x: i32, z: i32, bottom: f32, empty: [bool; 4]) -> u16 {
|
||||
let mut mask = 0;
|
||||
if bottom <= 0.0 {
|
||||
mask |= 1 << 1;
|
||||
}
|
||||
if bottom + 4.0 >= 0.0 {
|
||||
mask |= 1;
|
||||
}
|
||||
for (i, visible) in [z > 0, z < 0, x > 0, x < 0].into_iter().enumerate() {
|
||||
if empty[i] && visible {
|
||||
mask |= 1 << (i + 2);
|
||||
}
|
||||
}
|
||||
if x.abs() <= 1 && z.abs() <= 1 {
|
||||
mask |= 63 << 6;
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// Exterior faces first, then reversed interior faces, in vanilla emission order.
|
||||
pub fn faces(mask: u16) -> impl Iterator<Item = (u8, bool)> {
|
||||
[1, 0, 2, 3, 4, 5]
|
||||
.into_iter()
|
||||
.filter(move |d| mask & (1 << d) != 0)
|
||||
.map(|d| (d, false))
|
||||
.chain(
|
||||
(0..6)
|
||||
.filter(move |d| mask & (1 << (d + 6)) != 0)
|
||||
.map(|d| (d, true)),
|
||||
)
|
||||
}
|
||||
|
||||
/// `CloudRenderer.prepare`: the cell radius for the Cloud Distance option
|
||||
/// (in chunks), independent of the render distance.
|
||||
pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
|
||||
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
|
||||
}
|
||||
|
||||
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
|
||||
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
|
||||
(0..=2 * radius).flat_map(move |ring| {
|
||||
(-ring..=ring).flat_map(move |x| {
|
||||
let z = ring - x.abs();
|
||||
let visible = z <= radius && x * x + z * z <= radius * radius;
|
||||
(visible && z != 0)
|
||||
.then_some((x, -z))
|
||||
.into_iter()
|
||||
.chain(visible.then_some((x, z)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cells_are_nearest_first_and_unique() {
|
||||
let cells: Vec<_> = cells(2).collect();
|
||||
assert_eq!(cells[0], (0, 0));
|
||||
assert_eq!(cells.len(), 13);
|
||||
assert!(
|
||||
cells
|
||||
.windows(2)
|
||||
.all(|w| w[0].0.abs() + w[0].1.abs() <= w[1].0.abs() + w[1].1.abs())
|
||||
);
|
||||
assert_eq!(
|
||||
cells.iter().collect::<std::collections::HashSet<_>>().len(),
|
||||
cells.len()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn only_camera_facing_exterior_faces() {
|
||||
assert_eq!(face_mask(2, 2, 10.0, [true; 4]), 1 | 4 | 16);
|
||||
assert_eq!(face_mask(-2, -2, -5.0, [true; 4]), 2 | 8 | 32);
|
||||
assert_eq!(face_mask(2, 2, 0.0, [false; 4]), 3);
|
||||
}
|
||||
#[test]
|
||||
fn cloud_distance_sets_the_radius() {
|
||||
assert_eq!(radius_cells(2), 3);
|
||||
assert_eq!(radius_cells(12), 16);
|
||||
assert_eq!(radius_cells(128), 171);
|
||||
}
|
||||
#[test]
|
||||
fn connected_cells_have_no_exterior_walls() {
|
||||
assert_eq!(face_mask(2, 2, 10.0, [false; 4]), 1);
|
||||
}
|
||||
#[test]
|
||||
fn nearby_cells_keep_inward_faces_for_camera_inside_clouds() {
|
||||
assert_eq!(face_mask(0, 0, 0.0, [false; 4]), 3 | (63 << 6));
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,24 @@ pub fn load<'r>(root: &Path, registry: &'r BlockRegistry) -> Result<BlockCollisi
|
||||
Ok(BlockCollisionRegistry::new(Arc::new(cached))?)
|
||||
}
|
||||
|
||||
/// What powder snow's contextual collision asks of the colliding entity
|
||||
/// (`PowderSnowBlock.getCollisionShape`).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PowderSnowSubject {
|
||||
pub fall_distance: f64,
|
||||
/// `FallingBlockEntity`.
|
||||
pub falling_block: bool,
|
||||
/// A living entity with leather boots on its feet.
|
||||
pub leather_boots: bool,
|
||||
/// `EntityTypeTags.POWDER_SNOW_WALKABLE_MOBS`.
|
||||
pub walkable_type: bool,
|
||||
}
|
||||
|
||||
pub struct SceneCollisions<'w, 'r> {
|
||||
pub world: &'w World,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
/// The entity colliding, for powder snow; None: no fall, no boots.
|
||||
pub subject: Option<PowderSnowSubject>,
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
@@ -169,18 +184,18 @@ impl LiquidCollisionWorld for SceneCollisions<'_, '_> {
|
||||
}
|
||||
impl PowderSnowWorld for SceneCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
// Entities here are never falling blocks and wear no leather boots.
|
||||
fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result<PowderSnowEntity> {
|
||||
let subject = self.subject.unwrap_or_default();
|
||||
Ok(PowderSnowEntity {
|
||||
fall_distance: 0.0,
|
||||
falling_block: false,
|
||||
fall_distance: subject.fall_distance,
|
||||
falling_block: subject.falling_block,
|
||||
})
|
||||
}
|
||||
fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
Ok(self.subject.is_some_and(|s| s.walkable_type))
|
||||
}
|
||||
fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
Ok(self.subject.is_some_and(|s| s.leather_boots))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! The mouse handling every survival and container screen shares
|
||||
//! (`AbstractContainerScreen.mouseClicked` / `mouseReleased`): which slot id
|
||||
//! a click targets (-999 outside the window, `hasClickedOutside` with the
|
||||
//! recipe book's own rule) and which `ContainerInput` it sends. Shift is
|
||||
//! the event's own modifiers (`InputWithModifiers.hasShiftDown`: the
|
||||
//! physical Shift keys, never the remapped or toggled sneak key).
|
||||
|
||||
use crate::menu::Input;
|
||||
|
||||
/// The screen's window: `leftPos`, `topPos`, `imageWidth`, `imageHeight`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct Image {
|
||||
pub left: i32,
|
||||
pub top: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
/// The recipe book of an `AbstractRecipeBookScreen`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub struct Book {
|
||||
/// `RecipeBookComponent.isVisible`.
|
||||
pub visible: bool,
|
||||
/// `selectedTab.isHoveredOrFocused()`.
|
||||
pub selected_tab_hovered: bool,
|
||||
}
|
||||
|
||||
/// `RecipeBookComponent.IMAGE_WIDTH`.
|
||||
const BOOK_WIDTH: i32 = 147;
|
||||
|
||||
fn outside(mx: f64, my: f64, image: Image) -> bool {
|
||||
mx < f64::from(image.left)
|
||||
|| my < f64::from(image.top)
|
||||
|| mx >= f64::from(image.left + image.width)
|
||||
|| my >= f64::from(image.top + image.height)
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen.hasClickedOutside`, or
|
||||
/// `AbstractRecipeBookScreen.hasClickedOutside` when the screen has a book
|
||||
/// (`RecipeBookComponent.hasClickedOutside`: a click on the book, or with
|
||||
/// its selected tab hovered, is not outside).
|
||||
pub fn clicked_outside(mx: f64, my: f64, image: Image, book: Option<Book>) -> bool {
|
||||
let out = outside(mx, my, image);
|
||||
let Some(book) = book else {
|
||||
return out;
|
||||
};
|
||||
let book_outside = !book.visible || {
|
||||
let on_book = f64::from(image.left - BOOK_WIDTH) < mx
|
||||
&& mx < f64::from(image.left)
|
||||
&& f64::from(image.top) < my
|
||||
&& my < f64::from(image.top + image.height);
|
||||
out && !on_book && !book.selected_tab_hovered
|
||||
};
|
||||
book_outside && out
|
||||
}
|
||||
|
||||
/// What a click points at: a slot, outside the window (slot id -999), or
|
||||
/// neither (slot id -1).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Target<S> {
|
||||
Slot(S),
|
||||
Outside,
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl<S> Target<S> {
|
||||
/// Outside wins over a hovered slot, as in `mouseClicked`.
|
||||
pub fn of(slot: Option<S>, outside: bool) -> Self {
|
||||
match slot {
|
||||
_ if outside => Self::Outside,
|
||||
Some(s) => Self::Slot(s),
|
||||
None => Self::Nothing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The left, right and middle buttons (SDL's 1, 3 and 2).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Button {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
impl Button {
|
||||
/// `getContainerClickButton`: the button a menu click carries.
|
||||
pub fn menu_button(self) -> i32 {
|
||||
match self {
|
||||
Self::Left => 0,
|
||||
Self::Right => 1,
|
||||
Self::Middle => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The input state `mouseClicked`/`mouseReleased` keep between events.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Clicks {
|
||||
/// `isQuickCrafting` with `quickCraftingButton`.
|
||||
quick_crafting: Option<Button>,
|
||||
skip_next_release: bool,
|
||||
}
|
||||
|
||||
/// What a release does.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Release {
|
||||
/// `slotClicked` with this input on the release's target.
|
||||
Click(Input),
|
||||
/// `quickCraftToSlots` over the dragged slots.
|
||||
QuickCraft,
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl Clicks {
|
||||
/// A quick craft drag in progress, and the button that started it.
|
||||
pub fn quick_crafting(&self) -> Option<Button> {
|
||||
self.quick_crafting
|
||||
}
|
||||
|
||||
/// A press Horizon handled itself (a double click's `PICKUP_ALL`): its
|
||||
/// release clicks nothing more.
|
||||
pub fn skip_release(&mut self) {
|
||||
self.quick_crafting = None;
|
||||
self.skip_next_release = true;
|
||||
}
|
||||
|
||||
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
|
||||
/// `target`, if any. A press holding a stack starts a quick craft drag
|
||||
/// instead. `cloning` is `keyPickItem` with infinite materials.
|
||||
pub fn press<S>(
|
||||
&mut self,
|
||||
target: &Target<S>,
|
||||
button: Button,
|
||||
carried_empty: bool,
|
||||
shift: bool,
|
||||
cloning: bool,
|
||||
) -> Option<Input> {
|
||||
self.skip_next_release = false;
|
||||
if button == Button::Middle && !cloning {
|
||||
return None;
|
||||
}
|
||||
if matches!(target, Target::Nothing) || self.quick_crafting.is_some() {
|
||||
return None;
|
||||
}
|
||||
if !carried_empty {
|
||||
self.quick_crafting = Some(button);
|
||||
return None;
|
||||
}
|
||||
self.skip_next_release = true;
|
||||
Some(if cloning {
|
||||
Input::Clone
|
||||
} else if shift && !matches!(target, Target::Outside) {
|
||||
Input::QuickMove
|
||||
} else if matches!(target, Target::Outside) {
|
||||
Input::Throw
|
||||
} else {
|
||||
Input::Pickup
|
||||
})
|
||||
}
|
||||
|
||||
/// `mouseReleased` (no double click). `dragged` is whether the quick
|
||||
/// craft drag collected any slots; `pick_item` is the release of the
|
||||
/// `keyPickItem` button.
|
||||
pub fn release<S>(
|
||||
&mut self,
|
||||
target: &Target<S>,
|
||||
button: Button,
|
||||
carried_empty: bool,
|
||||
dragged: bool,
|
||||
shift: bool,
|
||||
pick_item: bool,
|
||||
) -> Release {
|
||||
if self.quick_crafting.is_some_and(|b| b != button) {
|
||||
self.quick_crafting = None;
|
||||
self.skip_next_release = true;
|
||||
return Release::Nothing;
|
||||
}
|
||||
if self.skip_next_release {
|
||||
self.skip_next_release = false;
|
||||
return Release::Nothing;
|
||||
}
|
||||
let quick_crafting = self.quick_crafting.take().is_some();
|
||||
if quick_crafting && dragged {
|
||||
Release::QuickCraft
|
||||
} else if carried_empty {
|
||||
Release::Nothing
|
||||
} else if pick_item {
|
||||
Release::Click(Input::Clone)
|
||||
} else if shift && !matches!(target, Target::Outside) {
|
||||
Release::Click(Input::QuickMove)
|
||||
} else {
|
||||
Release::Click(Input::Pickup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const INV: Image = Image {
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 176,
|
||||
height: 166,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn window_edges() {
|
||||
assert!(!clicked_outside(100.0, 50.0, INV, None));
|
||||
assert!(clicked_outside(99.9, 60.0, INV, None));
|
||||
assert!(clicked_outside(276.0, 60.0, INV, None));
|
||||
assert!(!clicked_outside(275.9, 215.9, INV, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_book_is_not_outside() {
|
||||
let book = Book {
|
||||
visible: true,
|
||||
selected_tab_hovered: false,
|
||||
};
|
||||
assert!(!clicked_outside(10.0, 100.0, INV, Some(book)));
|
||||
assert!(clicked_outside(-60.0, 100.0, INV, Some(book)));
|
||||
assert!(!clicked_outside(
|
||||
-60.0,
|
||||
100.0,
|
||||
INV,
|
||||
Some(Book {
|
||||
selected_tab_hovered: true,
|
||||
..book
|
||||
})
|
||||
));
|
||||
let closed = Book {
|
||||
visible: false,
|
||||
..book
|
||||
};
|
||||
assert!(clicked_outside(10.0, 100.0, INV, Some(closed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_carried_stack_thrown_outside() {
|
||||
let mut clicks = Clicks::default();
|
||||
let out = Target::<usize>::Outside;
|
||||
assert_eq!(clicks.press(&out, Button::Left, false, false, false), None);
|
||||
assert_eq!(
|
||||
clicks.release(&out, Button::Left, false, false, false, false),
|
||||
Release::Click(Input::Pickup)
|
||||
);
|
||||
// Shift never quick-moves outside, and an empty hand throws nothing.
|
||||
assert_eq!(
|
||||
clicks.press(&out, Button::Right, true, true, false),
|
||||
Some(Input::Throw)
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.release(&out, Button::Right, true, false, true, false),
|
||||
Release::Nothing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_button_cancels_the_drag() {
|
||||
let mut clicks = Clicks::default();
|
||||
let slot = Target::Slot(3);
|
||||
clicks.press(&slot, Button::Left, false, false, false);
|
||||
assert_eq!(
|
||||
clicks.release(&slot, Button::Right, false, true, false, false),
|
||||
Release::Nothing
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.release(&slot, Button::Left, false, true, false, false),
|
||||
Release::Nothing
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.release(&slot, Button::Left, false, true, true, false),
|
||||
Release::Click(Input::QuickMove)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -427,6 +427,32 @@ fn remainder(item: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
impl Furnace {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
//! Each connection sends commands as text lines and receives one JSON line per
|
||||
//! command. Commands are executed on the game thread between frames; replies
|
||||
//! that depend on a later frame (screenshots, benchmarks) arrive when ready.
|
||||
//!
|
||||
//! Two transports carry the same lines. On Linux and macOS the endpoint is a
|
||||
//! Unix domain socket. Where those don't exist (Windows), or when the endpoint
|
||||
//! path ends in `.json`, the game listens on a random localhost TCP port and
|
||||
//! writes `{"transport":"tcp","port":N,"token":"..."}` to the path, readable
|
||||
//! only by its user; a client's first line must then be `auth TOKEN`.
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::{Ipv4Addr, TcpListener, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
@@ -12,54 +19,128 @@ use std::{
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use std::os::unix::net::{UnixListener, UnixStream};
|
||||
#[cfg(not(unix))]
|
||||
pub use unsupported::{UnixListener, UnixStream};
|
||||
|
||||
/// Windows' standard library has no Unix domain sockets: there the control
|
||||
/// socket reports itself unavailable and the game runs without it.
|
||||
#[cfg(not(unix))]
|
||||
mod unsupported {
|
||||
use std::{io, path::Path};
|
||||
/// A connection to the control endpoint, over either transport.
|
||||
pub enum Stream {
|
||||
#[cfg(unix)]
|
||||
Unix(UnixStream),
|
||||
Tcp(TcpStream),
|
||||
}
|
||||
|
||||
fn unavailable() -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"the control socket needs Unix domain sockets (Linux or macOS)",
|
||||
)
|
||||
impl Stream {
|
||||
pub fn try_clone(&self) -> io::Result<Self> {
|
||||
Ok(match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => Self::Unix(s.try_clone()?),
|
||||
Self::Tcp(s) => Self::Tcp(s.try_clone()?),
|
||||
})
|
||||
}
|
||||
pub struct UnixStream;
|
||||
impl UnixStream {
|
||||
pub fn connect<P: AsRef<Path>>(_: P) -> io::Result<Self> {
|
||||
Err(unavailable())
|
||||
}
|
||||
pub fn try_clone(&self) -> io::Result<Self> {
|
||||
Err(unavailable())
|
||||
/// A read timeout (None waits forever).
|
||||
pub fn set_read_timeout(&self, timeout: Option<std::time::Duration>) -> io::Result<()> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => s.set_read_timeout(timeout),
|
||||
Self::Tcp(s) => s.set_read_timeout(timeout),
|
||||
}
|
||||
}
|
||||
impl io::Read for UnixStream {
|
||||
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
|
||||
Err(unavailable())
|
||||
}
|
||||
impl Read for Stream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => s.read(buf),
|
||||
Self::Tcp(s) => s.read(buf),
|
||||
}
|
||||
}
|
||||
impl io::Write for UnixStream {
|
||||
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
|
||||
Err(unavailable())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => s.write(buf),
|
||||
Self::Tcp(s) => s.write(buf),
|
||||
}
|
||||
}
|
||||
pub struct UnixListener;
|
||||
impl UnixListener {
|
||||
pub fn bind<P: AsRef<Path>>(_: P) -> io::Result<Self> {
|
||||
Err(unavailable())
|
||||
}
|
||||
pub fn incoming(&self) -> std::iter::Empty<io::Result<UnixStream>> {
|
||||
std::iter::empty()
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(s) => s.flush(),
|
||||
Self::Tcp(s) => s.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the endpoint at `path` is a TCP port described by a JSON file.
|
||||
pub fn uses_tcp(path: &Path) -> bool {
|
||||
!cfg!(unix) || path.extension().is_some_and(|e| e == "json")
|
||||
}
|
||||
|
||||
/// The TCP endpoint file: (port, token).
|
||||
fn read_endpoint(path: &Path) -> io::Result<(u16, String)> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let bad = || io::Error::new(io::ErrorKind::InvalidData, "not a control endpoint file");
|
||||
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| bad())?;
|
||||
let port = v["port"]
|
||||
.as_u64()
|
||||
.and_then(|p| u16::try_from(p).ok())
|
||||
.ok_or_else(bad)?;
|
||||
let token = v["token"].as_str().ok_or_else(bad)?.to_owned();
|
||||
Ok((port, token))
|
||||
}
|
||||
|
||||
/// Connect to a running game's control endpoint (and authenticate over TCP).
|
||||
pub fn connect(path: &Path) -> io::Result<Stream> {
|
||||
#[cfg(unix)]
|
||||
if !uses_tcp(path) {
|
||||
return UnixStream::connect(path).map(Stream::Unix);
|
||||
}
|
||||
let (port, token) = read_endpoint(path)?;
|
||||
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port))?;
|
||||
stream.set_nodelay(true)?;
|
||||
writeln!(stream, "auth {token}")?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(stream.try_clone()?).read_line(&mut reply)?;
|
||||
if !reply.contains("\"ok\"") {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!("the game refused the token: {}", reply.trim()),
|
||||
));
|
||||
}
|
||||
Ok(Stream::Tcp(stream))
|
||||
}
|
||||
|
||||
/// 128 random bits as hex, from the standard library's OS-seeded hasher keys.
|
||||
fn random_token() -> String {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
(0..2)
|
||||
.map(|i| {
|
||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
h.write_u64(i);
|
||||
h.write_u128(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos()),
|
||||
);
|
||||
h.write_u32(std::process::id());
|
||||
format!("{:016x}", h.finish())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write the endpoint file so that only this user can read it.
|
||||
fn write_private(path: &Path, text: &str) -> io::Result<()> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
options.open(path)?.write_all(text.as_bytes())
|
||||
}
|
||||
|
||||
/// Constant-time comparison of the presented token.
|
||||
fn same_token(a: &str, b: &str) -> bool {
|
||||
a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |d, (x, y)| d | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// One command line from a client, with the channel for its JSON reply.
|
||||
pub struct Request {
|
||||
pub line: String,
|
||||
@@ -71,33 +152,55 @@ pub struct Server {
|
||||
requests: mpsc::Receiver<Request>,
|
||||
}
|
||||
|
||||
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime directory.
|
||||
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime
|
||||
/// directory (`horizon-control.json` in the temporary directory on Windows).
|
||||
pub fn default_path() -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("HORIZON_CONTROL") {
|
||||
return path.into();
|
||||
}
|
||||
if !cfg!(unix) {
|
||||
return std::env::temp_dir().join("horizon-control.json");
|
||||
}
|
||||
let dir = std::env::var_os("XDG_RUNTIME_DIR").map_or_else(std::env::temp_dir, PathBuf::from);
|
||||
dir.join("horizon-control.sock")
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn start(path: &Path) -> std::io::Result<Self> {
|
||||
// A socket left by a crashed game refuses connections; replace it.
|
||||
if path.exists() && UnixStream::connect(path).is_err() {
|
||||
pub fn start(path: &Path) -> io::Result<Self> {
|
||||
// An endpoint left by a crashed game refuses connections; replace it.
|
||||
if path.exists() && connect(path).is_err() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
let listener = UnixListener::bind(path)?;
|
||||
let (sender, requests) = mpsc::channel();
|
||||
thread::Builder::new()
|
||||
.name("horizon-control".into())
|
||||
.spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let sender = sender.clone();
|
||||
let _ = thread::Builder::new()
|
||||
.name("horizon-control-client".into())
|
||||
.spawn(move || serve(stream, &sender));
|
||||
}
|
||||
})?;
|
||||
if uses_tcp(path) {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let token = random_token();
|
||||
let port = listener.local_addr()?.port();
|
||||
write_private(
|
||||
path,
|
||||
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
|
||||
.to_string(),
|
||||
)?;
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
move |stream: TcpStream| {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let mut stream = Stream::Tcp(stream);
|
||||
authenticate(&mut stream, &token).then_some(stream)
|
||||
},
|
||||
)?;
|
||||
} else {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let listener = UnixListener::bind(path)?;
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
|s| Some(Stream::Unix(s)),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
path: path.to_owned(),
|
||||
requests,
|
||||
@@ -111,13 +214,68 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept connections on a thread, one more thread per client.
|
||||
fn spawn_listener<S: Send + 'static>(
|
||||
mut accept_next: impl FnMut() -> io::Result<S> + Send + 'static,
|
||||
sender: mpsc::Sender<Request>,
|
||||
accept: impl Fn(S) -> Option<Stream> + Send + Sync + 'static,
|
||||
) -> io::Result<()> {
|
||||
let accept = std::sync::Arc::new(accept);
|
||||
thread::Builder::new()
|
||||
.name("horizon-control".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let Ok(stream) = accept_next() else {
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
continue;
|
||||
};
|
||||
let sender = sender.clone();
|
||||
let accept = accept.clone();
|
||||
let _ = thread::Builder::new()
|
||||
.name("horizon-control-client".into())
|
||||
.spawn(move || {
|
||||
if let Some(stream) = accept(stream) {
|
||||
serve(stream, &sender);
|
||||
}
|
||||
});
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The first line of a TCP client must be `auth TOKEN`.
|
||||
fn authenticate(stream: &mut Stream, token: &str) -> bool {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
// Read the line byte by byte so nothing after it is buffered away.
|
||||
while line.len() < 256 {
|
||||
match stream.read(&mut byte) {
|
||||
Ok(1) if byte[0] == b'\n' => break,
|
||||
Ok(1) => line.push(byte[0]),
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
let line = String::from_utf8_lossy(&line);
|
||||
let ok = line
|
||||
.trim()
|
||||
.strip_prefix("auth ")
|
||||
.is_some_and(|t| same_token(t.trim(), token));
|
||||
let reply = if ok {
|
||||
r#"{"ok":true}"#
|
||||
} else {
|
||||
r#"{"error":"bad or missing token: the first line must be auth TOKEN"}"#
|
||||
};
|
||||
let _ = writeln!(stream, "{reply}");
|
||||
ok
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: UnixStream, sender: &mpsc::Sender<Request>) {
|
||||
fn serve(stream: Stream, sender: &mpsc::Sender<Request>) {
|
||||
let Ok(mut writer) = stream.try_clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -140,6 +298,55 @@ fn serve(stream: UnixStream, sender: &mpsc::Sender<Request>) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tcp_endpoint_round_trip() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("horizon-ctl-test-{}.json", std::process::id()));
|
||||
let server = Server::start(&path).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("\"tcp\""));
|
||||
let mut client = connect(&path).unwrap();
|
||||
writeln!(client, "status").unwrap();
|
||||
let request = loop {
|
||||
if let Some(r) = server.try_recv() {
|
||||
break r;
|
||||
}
|
||||
thread::sleep(std::time::Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!(request.line, "status");
|
||||
request.reply.send(r#"{"ok":true}"#.into()).unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(client).read_line(&mut reply).unwrap();
|
||||
assert_eq!(reply.trim(), r#"{"ok":true}"#);
|
||||
drop(server);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_refuses_a_wrong_token() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("horizon-ctl-bad-{}.json", std::process::id()));
|
||||
let _server = Server::start(&path).unwrap();
|
||||
let (port, _) = read_endpoint(&path).unwrap();
|
||||
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
writeln!(stream, "auth nope").unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(stream).read_line(&mut reply).unwrap();
|
||||
assert!(reply.contains("error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_differ() {
|
||||
assert_ne!(random_token(), random_token());
|
||||
assert_eq!(random_token().len(), 32);
|
||||
assert!(same_token("abc", "abc") && !same_token("abc", "abd") && !same_token("a", "ab"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame durations since the last reset, summarized like the Java recorder,
|
||||
/// with the CPU part of each frame and GPU times from timestamp queries.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -1061,7 +1061,7 @@ impl Special {
|
||||
}
|
||||
}
|
||||
/// `DyedItemColor.applyDyes` over dye indexes.
|
||||
fn apply_dyes(current: Option<i32>, dyes: &[usize]) -> i32 {
|
||||
pub(crate) fn apply_dyes(current: Option<i32>, dyes: &[usize]) -> i32 {
|
||||
let (mut r, mut g, mut b, mut intensity, mut n) = (0i32, 0i32, 0i32, 0i32, 0i32);
|
||||
let mut add = |rgb: u32| {
|
||||
let (red, green, blue) = (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
|
||||
//! contents as built by the game's own display generators
|
||||
//! (`creative-tabs.json`), a 9x5 scrolling grid and the search tab.
|
||||
use crate::Result;
|
||||
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
|
||||
use crate::{Result, items::ItemStack};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
@@ -10,19 +10,35 @@ pub struct Tab {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub icon: String,
|
||||
pub items: Vec<String>,
|
||||
pub items: Vec<ItemStack>,
|
||||
pub top: bool,
|
||||
pub column: u32,
|
||||
pub search: bool,
|
||||
pub inventory: bool,
|
||||
}
|
||||
/// What the client's creative search trees (SessionSearchTrees) index for one
|
||||
/// stack of the search tab: its creative tooltip lines and its item tags.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct SearchEntry {
|
||||
/// The item id's namespace and path, lower-cased.
|
||||
namespace: String,
|
||||
path: String,
|
||||
/// Tooltip lines, lower-cased; empty when the resources predate them.
|
||||
lines: Vec<String>,
|
||||
/// Item tags as (namespace, path), lower-cased.
|
||||
tags: Vec<(String, String)>,
|
||||
}
|
||||
pub struct Creative {
|
||||
pub tabs: Vec<Tab>,
|
||||
pub selected: usize,
|
||||
/// Scroll position 0..1.
|
||||
/// Scroll position 0..1 (scrollOffs).
|
||||
pub scroll: f32,
|
||||
/// The scrollbar is held (scrolling): dragging moves it.
|
||||
pub scrolling: bool,
|
||||
pub search: String,
|
||||
all_items: Vec<String>,
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
}
|
||||
/// Display names from the pinned en_us language file.
|
||||
pub struct Names {
|
||||
@@ -51,26 +67,94 @@ impl Names {
|
||||
impl Creative {
|
||||
pub const WIDTH: f32 = 195.0;
|
||||
pub const HEIGHT: f32 = 136.0;
|
||||
/// The scrollbar track (insideScrollbar), relative to the screen origin.
|
||||
pub const SCROLL_X: f32 = 175.0;
|
||||
pub const SCROLL_Y: f32 = 18.0;
|
||||
pub const SCROLL_WIDTH: f32 = 14.0;
|
||||
pub const SCROLL_HEIGHT: f32 = 112.0;
|
||||
/// `creative-inventory.json` (VanillaCreativeTabsExporter); resources
|
||||
/// prepared before it fall back to `creative-tabs.json`'s item ids.
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let raw: Value = serde_json::from_slice(&fs::read(root.join("creative-tabs.json"))?)?;
|
||||
let path = root.join("creative-inventory.json");
|
||||
if path.exists() {
|
||||
Self::from_json(&serde_json::from_slice(&fs::read(path)?)?)
|
||||
} else {
|
||||
Self::from_legacy(&serde_json::from_slice(&fs::read(
|
||||
root.join("creative-tabs.json"),
|
||||
)?)?)
|
||||
}
|
||||
}
|
||||
pub fn from_json(raw: &Value) -> Result<Self> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut all_items: Vec<String> = Vec::new();
|
||||
for t in raw.as_array().into_iter().flatten() {
|
||||
let items: Vec<String> = t["items"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
for i in &items {
|
||||
if !all_items.contains(i) {
|
||||
all_items.push(i.clone());
|
||||
let mut search_items = Vec::new();
|
||||
let mut search_index = Vec::new();
|
||||
for t in raw["tabs"].as_array().into_iter().flatten() {
|
||||
let mut items = Vec::new();
|
||||
for v in t["items"].as_array().into_iter().flatten() {
|
||||
let snbt = v.as_str().unwrap_or("-");
|
||||
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
|
||||
items.push(stack);
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
if kind == "SEARCH" {
|
||||
let strings = |v: &Value| -> Vec<String> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| s.as_str().map(java_lower))
|
||||
.collect()
|
||||
};
|
||||
for (i, stack) in items.iter().enumerate() {
|
||||
let (namespace, path) = split_id(&java_lower(&stack.item));
|
||||
search_index.push(SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
lines: strings(&t["lines"][i]),
|
||||
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
|
||||
});
|
||||
}
|
||||
search_items = items.clone();
|
||||
}
|
||||
if kind == "HOTBAR" || t["visible"] == false {
|
||||
continue;
|
||||
}
|
||||
let icon = t["icon"]
|
||||
.as_str()
|
||||
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
});
|
||||
}
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
}
|
||||
/// The old export: item ids per tab, no components, tooltips or tags.
|
||||
fn from_legacy(raw: &Value) -> Result<Self> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut search_items: Vec<ItemStack> = Vec::new();
|
||||
for t in raw.as_array().into_iter().flatten() {
|
||||
let items: Vec<ItemStack> = t["items"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str().map(|id| ItemStack::new(id, 1)))
|
||||
.collect();
|
||||
if t["id"] == "minecraft:hotbar" || t["id"] == "minecraft:op_blocks" {
|
||||
continue;
|
||||
}
|
||||
for i in &items {
|
||||
if !search_items.iter().any(|s| s.item == i.item) {
|
||||
search_items.push(i.clone());
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
@@ -82,31 +166,101 @@ impl Creative {
|
||||
inventory: kind == "INVENTORY",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
let search_index = search_items
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let (namespace, path) = split_id(&java_lower(&s.item));
|
||||
SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
}
|
||||
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
|
||||
Self {
|
||||
tabs,
|
||||
selected: 0,
|
||||
scroll: 0.0,
|
||||
scrolling: false,
|
||||
search: String::new(),
|
||||
all_items,
|
||||
})
|
||||
search_items,
|
||||
search_index,
|
||||
}
|
||||
}
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
}
|
||||
/// Items shown by the selected tab (the search tab filters by name).
|
||||
pub fn items(&self, names: &Names) -> Vec<String> {
|
||||
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
|
||||
pub fn select(&mut self, tab: usize) {
|
||||
if tab != self.selected {
|
||||
self.search.clear();
|
||||
}
|
||||
self.selected = tab;
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
/// The search tab's stacks.
|
||||
pub fn search_items(&self) -> &[ItemStack] {
|
||||
&self.search_items
|
||||
}
|
||||
/// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults
|
||||
/// shows for `term`: the whole tab when empty, the tag tree after `#`, else
|
||||
/// the name tree, both over vanilla's suffix arrays (substring matches,
|
||||
/// results in the tab's order).
|
||||
pub fn search_results(&self, term: &str, names: &Names) -> Vec<usize> {
|
||||
let all = 0..self.search_index.len();
|
||||
if term.is_empty() {
|
||||
return all.collect();
|
||||
}
|
||||
if let Some(tag) = term.strip_prefix('#') {
|
||||
// IdSearchTree over the stacks' tags.
|
||||
let text = java_lower(tag);
|
||||
return all
|
||||
.filter(|&i| {
|
||||
let tags = &self.search_index[i].tags;
|
||||
match text.split_once(':') {
|
||||
None => any(tags.iter().map(|t| t.1.as_str()), &text),
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
any(tags.iter().map(|t| t.0.as_str()), ns)
|
||||
&& any(tags.iter().map(|t| t.1.as_str()), path)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// FullTextSearchTree: tooltip lines, and the item id after a colon.
|
||||
let text = java_lower(term);
|
||||
all.filter(|&i| {
|
||||
let e = &self.search_index[i];
|
||||
let fallback;
|
||||
let lines: &[String] = if e.lines.is_empty() {
|
||||
fallback = [java_lower(&names.item(&self.search_items[i].item))];
|
||||
&fallback
|
||||
} else {
|
||||
&e.lines
|
||||
};
|
||||
match text.split_once(':') {
|
||||
None => any(lines.iter().map(String::as_str), &text),
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
e.namespace.contains(ns)
|
||||
&& (e.path.contains(path) || any(lines.iter().map(String::as_str), path))
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Items shown by the selected tab (the search tab filters by `search`).
|
||||
pub fn items(&self, names: &Names) -> Vec<ItemStack> {
|
||||
let tab = self.tab();
|
||||
if tab.search {
|
||||
let query = self.search.to_lowercase();
|
||||
return self
|
||||
.all_items
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
query.is_empty()
|
||||
|| names.item(i).to_lowercase().contains(&query)
|
||||
|| i.contains(&query)
|
||||
})
|
||||
.cloned()
|
||||
.search_results(&self.search, names)
|
||||
.into_iter()
|
||||
.map(|i| self.search_items[i].clone())
|
||||
.collect();
|
||||
}
|
||||
tab.items.clone()
|
||||
@@ -114,10 +268,60 @@ impl Creative {
|
||||
pub fn rows(&self, names: &Names) -> usize {
|
||||
self.items(names).len().div_ceil(9)
|
||||
}
|
||||
/// First visible grid row for the scroll position.
|
||||
/// ItemPickerMenu.calculateRowCount: rows beyond the five shown.
|
||||
fn hidden_rows(&self, names: &Names) -> i32 {
|
||||
self.rows(names) as i32 - 5
|
||||
}
|
||||
/// First visible grid row for the scroll position (getRowIndexForScroll).
|
||||
pub fn first_row(&self, names: &Names) -> usize {
|
||||
let hidden = self.rows(names).saturating_sub(5);
|
||||
(self.scroll * hidden as f32 + 0.5) as usize
|
||||
((self.scroll * self.hidden_rows(names) as f32 + 0.5) as i32).max(0) as usize
|
||||
}
|
||||
/// CreativeModeInventoryScreen.canScroll: more than five rows, on a tab with a scrollbar.
|
||||
pub fn can_scroll(&self, names: &Names) -> bool {
|
||||
!self.tab().inventory && self.items(names).len() > 45
|
||||
}
|
||||
/// insideScrollbar, for a point relative to the screen origin.
|
||||
pub fn inside_scrollbar(&self, x: f32, y: f32) -> bool {
|
||||
x >= Self::SCROLL_X
|
||||
&& y >= Self::SCROLL_Y
|
||||
&& x < Self::SCROLL_X + Self::SCROLL_WIDTH
|
||||
&& y < Self::SCROLL_Y + Self::SCROLL_HEIGHT
|
||||
&& !self.tab().inventory
|
||||
}
|
||||
/// mouseClicked on the scrollbar: it is held only if the tab can scroll
|
||||
/// (the thumb doesn't move until the mouse does).
|
||||
pub fn press_scrollbar(&mut self, x: f32, y: f32, names: &Names) -> bool {
|
||||
if !self.inside_scrollbar(x, y) {
|
||||
return false;
|
||||
}
|
||||
self.scrolling = self.can_scroll(names);
|
||||
true
|
||||
}
|
||||
/// mouseDragged while the scrollbar is held, at a cursor height relative to
|
||||
/// the screen origin, wherever the cursor is (even off the track or the window).
|
||||
pub fn drag_scrollbar(&mut self, y: f32) -> bool {
|
||||
if !self.scrolling {
|
||||
return false;
|
||||
}
|
||||
let (top, bottom) = (Self::SCROLL_Y, Self::SCROLL_Y + Self::SCROLL_HEIGHT);
|
||||
self.scroll = ((y - top - 7.5) / (bottom - top - 15.0)).clamp(0.0, 1.0);
|
||||
true
|
||||
}
|
||||
/// mouseReleased.
|
||||
pub fn release(&mut self) {
|
||||
self.scrolling = false;
|
||||
}
|
||||
/// mouseScrolled: ItemPickerMenu.subtractInputFromScroll.
|
||||
pub fn wheel(&mut self, amount: f64, names: &Names) {
|
||||
if !self.can_scroll(names) {
|
||||
return;
|
||||
}
|
||||
let rows = self.hidden_rows(names) as f64;
|
||||
self.scroll = (self.scroll - (amount / rows) as f32).clamp(0.0, 1.0);
|
||||
}
|
||||
/// The scroller sprite's offset from the top of the track.
|
||||
pub fn scroller_offset(&self) -> f32 {
|
||||
((Self::SCROLL_HEIGHT - 17.0) * self.scroll) as i32 as f32
|
||||
}
|
||||
/// Tab button position relative to the screen origin (CreativeModeInventoryScreen.getTabX/Y).
|
||||
pub fn tab_position(tab: &Tab) -> (f32, f32) {
|
||||
@@ -130,3 +334,163 @@ impl Creative {
|
||||
(x, y)
|
||||
}
|
||||
}
|
||||
/// Whether a suffix array of `strings` has a suffix starting with `text`:
|
||||
/// some string contains it (none for no strings, even when `text` is empty).
|
||||
fn any<'a>(strings: impl IntoIterator<Item = &'a str>, text: &str) -> bool {
|
||||
strings.into_iter().any(|s| s.contains(text))
|
||||
}
|
||||
/// String.toLowerCase(Locale.ROOT).
|
||||
fn java_lower(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
}
|
||||
/// String.trim: strips code points up to U+0020.
|
||||
fn java_trim(s: &str) -> &str {
|
||||
s.trim_matches(|c: char| c <= ' ')
|
||||
}
|
||||
/// Identifier.parse's namespace and path (default namespace `minecraft`).
|
||||
fn split_id(id: &str) -> (String, String) {
|
||||
match id.split_once(':') {
|
||||
Some((ns, path)) => (ns.to_owned(), path.to_owned()),
|
||||
None => ("minecraft".to_owned(), id.to_owned()),
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn creative(items: usize) -> Creative {
|
||||
let mut stacks: Vec<String> = ["anvil", "crafting_table", "wooden_hoe"]
|
||||
.iter()
|
||||
.map(|i| format!("{{count:1,id:\"minecraft:{i}\"}}"))
|
||||
.collect();
|
||||
stacks.extend((0..items).map(|i| format!("{{count:1,id:\"minecraft:item_{i}\"}}")));
|
||||
let mut lines = vec![
|
||||
vec!["Anvil"],
|
||||
vec!["Crafting Table"],
|
||||
vec!["Wooden Hoe", "When in Main Hand:", "-3 Attack Speed"],
|
||||
];
|
||||
lines.extend(vec![vec!["Thing"]; items]);
|
||||
let mut tags = vec![
|
||||
vec!["minecraft:anvil"],
|
||||
vec![],
|
||||
vec!["minecraft:hoes", "minecraft:enchantable/mining"],
|
||||
];
|
||||
tags.extend(vec![vec![]; items]);
|
||||
let raw = serde_json::json!({"schema": 2, "tabs": [
|
||||
{"id": "minecraft:functional_blocks", "type": "CATEGORY", "row": "TOP", "column": 3, "title": "Functional Blocks",
|
||||
"icon": "{count:1,id:\"minecraft:oak_sign\"}", "visible": true,
|
||||
"items": ["{count:1,id:\"minecraft:anvil\"}", "{count:1,id:\"minecraft:crafting_table\"}"]},
|
||||
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
|
||||
"icon": "{count:1,id:\"minecraft:command_block\"}", "visible": false, "items": []},
|
||||
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
|
||||
"icon": "{count:1,id:\"minecraft:compass\"}", "visible": true,
|
||||
"items": stacks, "lines": lines, "tags": tags},
|
||||
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
|
||||
"icon": "{count:1,id:\"minecraft:chest\"}", "visible": true, "items": []}
|
||||
]});
|
||||
Creative::from_json(&raw).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_tabs_are_skipped() {
|
||||
let c = creative(0);
|
||||
let ids: Vec<&str> = c.tabs.iter().map(|t| t.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"minecraft:functional_blocks",
|
||||
"minecraft:search",
|
||||
"minecraft:inventory"
|
||||
]
|
||||
);
|
||||
assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil");
|
||||
let mut c = c;
|
||||
c.select(1);
|
||||
c.search = "anvil".into();
|
||||
c.scroll = 0.5;
|
||||
c.select(1);
|
||||
assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0));
|
||||
c.select(0);
|
||||
assert_eq!(c.search, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_vanilla_trees() {
|
||||
let c = creative(0);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
let q = |s: &str| c.search_results(s, &names);
|
||||
assert_eq!(q(""), [0, 1, 2]);
|
||||
assert_eq!(q("ANVIL"), [0]);
|
||||
assert_eq!(q("table"), [1]);
|
||||
assert_eq!(q("a"), [0, 1, 2]);
|
||||
// Tooltip lines, not just names; no trimming without a colon.
|
||||
assert_eq!(q("attack speed"), [2]);
|
||||
assert_eq!(q(" anvil"), Vec::<usize>::new());
|
||||
// A colon searches the id: namespace, then the path or a tooltip line.
|
||||
assert_eq!(q("minecraft:crafting_t"), [1]);
|
||||
assert_eq!(q(" minecraft : hoe "), [2]);
|
||||
assert_eq!(q("mine:anvil"), [0]);
|
||||
assert_eq!(q("other:anvil"), Vec::<usize>::new());
|
||||
assert_eq!(q(":"), [0, 1, 2]);
|
||||
// Tags after "#": only stacks with tags.
|
||||
assert_eq!(q("#"), [0, 2]);
|
||||
assert_eq!(q("#hoe"), [2]);
|
||||
assert_eq!(q("#minecraft:enchantable/"), [2]);
|
||||
assert_eq!(q("#craft"), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_drag_follows_the_mouse_while_held() {
|
||||
let mut c = creative(90);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
c.selected = 1;
|
||||
// Pressing the track holds the scrollbar without moving it.
|
||||
assert!(c.press_scrollbar(180.0, 60.0, &names));
|
||||
assert!(c.scrolling);
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
// Dragging maps the cursor height onto the track (minus half the thumb).
|
||||
assert!(c.drag_scrollbar(18.0 + 7.5 + 48.5));
|
||||
assert!((c.scroll - 0.5).abs() < 1e-6);
|
||||
// The cursor may leave the track, the panel or the window: it keeps scrolling.
|
||||
c.drag_scrollbar(400.0);
|
||||
assert_eq!(c.scroll, 1.0);
|
||||
c.drag_scrollbar(-50.0);
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
c.drag_scrollbar(18.0 + 7.5 + 97.0 * 0.25);
|
||||
assert!((c.scroll - 0.25).abs() < 1e-6);
|
||||
let rows = (93usize.div_ceil(9) - 5) as f32;
|
||||
assert_eq!(c.first_row(&names), (0.25 * rows + 0.5) as usize);
|
||||
// Releasing stops it.
|
||||
c.release();
|
||||
assert!(!c.drag_scrollbar(400.0));
|
||||
assert!((c.scroll - 0.25).abs() < 1e-6);
|
||||
// Off the track, or with too few items, it isn't held.
|
||||
assert!(!c.press_scrollbar(170.0, 60.0, &names));
|
||||
assert!(!c.press_scrollbar(180.0, 130.0, &names));
|
||||
c.selected = 0;
|
||||
assert!(c.press_scrollbar(180.0, 60.0, &names));
|
||||
assert!(!c.scrolling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_moves_one_row_per_notch() {
|
||||
let mut c = creative(90);
|
||||
let names = Names {
|
||||
map: HashMap::new(),
|
||||
};
|
||||
c.selected = 1;
|
||||
let rows = (93usize.div_ceil(9) - 5) as f32;
|
||||
c.wheel(-1.0, &names);
|
||||
assert!((c.scroll - 1.0 / rows).abs() < 1e-6);
|
||||
assert_eq!(c.first_row(&names), 1);
|
||||
c.wheel(5.0, &names);
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
assert_eq!(c.scroller_offset(), 0.0);
|
||||
c.scroll = 1.0;
|
||||
assert_eq!(c.scroller_offset(), 95.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! The debug keys (26.3 `KeyboardHandler`): `key.debug.modifier` (F3) held
|
||||
//! with another key runs a debug action (`handleDebugKeys`), released alone
|
||||
//! it toggles the debug overlay (`key.debug.overlay`, F3 too by default);
|
||||
//! with Escape it pauses without the menu; held with `key.debug.crash` (C)
|
||||
//! for ten seconds it crashes the game (`tick`).
|
||||
use crate::keybinds::KeyBinds;
|
||||
|
||||
/// A debug mapping's action, as `handleDebugKeys` tests them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DebugAction {
|
||||
ReloadChunks,
|
||||
ShowHitboxes,
|
||||
ClearChat,
|
||||
ShowChunkBorders,
|
||||
AdvancedTooltips,
|
||||
CopyRecreateCommand,
|
||||
Spectate,
|
||||
SwitchGameMode,
|
||||
DebugOptions,
|
||||
FocusPause,
|
||||
DumpDynamicTextures,
|
||||
ReloadResourcePacks,
|
||||
Profiling,
|
||||
CopyLocation,
|
||||
DumpVersion,
|
||||
ProfilingChart,
|
||||
FpsCharts,
|
||||
NetworkCharts,
|
||||
LightmapTexture,
|
||||
ImprovedTransparency,
|
||||
}
|
||||
|
||||
/// The mappings `handleDebugKeys` tests, in its order.
|
||||
pub const ACTIONS: [(&str, DebugAction); 20] = [
|
||||
("key.debug.reloadChunk", DebugAction::ReloadChunks),
|
||||
("key.debug.showHitboxes", DebugAction::ShowHitboxes),
|
||||
("key.debug.clearChat", DebugAction::ClearChat),
|
||||
("key.debug.showChunkBorders", DebugAction::ShowChunkBorders),
|
||||
(
|
||||
"key.debug.showAdvancedTooltips",
|
||||
DebugAction::AdvancedTooltips,
|
||||
),
|
||||
(
|
||||
"key.debug.copyRecreateCommand",
|
||||
DebugAction::CopyRecreateCommand,
|
||||
),
|
||||
("key.debug.spectate", DebugAction::Spectate),
|
||||
("key.debug.switchGameMode", DebugAction::SwitchGameMode),
|
||||
("key.debug.debugOptions", DebugAction::DebugOptions),
|
||||
("key.debug.focusPause", DebugAction::FocusPause),
|
||||
(
|
||||
"key.debug.dumpDynamicTextures",
|
||||
DebugAction::DumpDynamicTextures,
|
||||
),
|
||||
(
|
||||
"key.debug.reloadResourcePacks",
|
||||
DebugAction::ReloadResourcePacks,
|
||||
),
|
||||
("key.debug.profiling", DebugAction::Profiling),
|
||||
("key.debug.copyLocation", DebugAction::CopyLocation),
|
||||
("key.debug.dumpVersion", DebugAction::DumpVersion),
|
||||
("key.debug.profilingChart", DebugAction::ProfilingChart),
|
||||
("key.debug.fpsCharts", DebugAction::FpsCharts),
|
||||
("key.debug.networkCharts", DebugAction::NetworkCharts),
|
||||
("key.debug.lightmapTexture", DebugAction::LightmapTexture),
|
||||
(
|
||||
"key.debug.improvedTransparency",
|
||||
DebugAction::ImprovedTransparency,
|
||||
),
|
||||
];
|
||||
|
||||
/// GLFW's key actions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KeyAction {
|
||||
Release,
|
||||
Press,
|
||||
Repeat,
|
||||
}
|
||||
|
||||
/// The screen open when a key reaches the game (after the screen itself
|
||||
/// passed on it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OpenScreen {
|
||||
None,
|
||||
/// `PauseScreen` without its menu (F3+Escape).
|
||||
PauseWithoutMenu,
|
||||
GameModeSwitcher,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What the game does for `KeyboardHandler.keyPress`.
|
||||
pub trait DebugHost {
|
||||
fn screen(&self) -> OpenScreen;
|
||||
/// `DebugScreenEntries.toggleDebugOverlay`.
|
||||
fn toggle_overlay(&mut self);
|
||||
/// `Minecraft.pauseGame(suppressPauseMenu)`.
|
||||
fn pause_game(&mut self, without_menu: bool);
|
||||
/// One matching mapping of `handleDebugKeys`; whether its condition held
|
||||
/// (vanilla's `debugAction = true`). `shift`: `event.hasShiftDown()`.
|
||||
fn debug_action(&mut self, action: DebugAction, shift: bool) -> bool;
|
||||
}
|
||||
|
||||
/// What `KeyboardHandler.tick` reports while the debug crash keys are held.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CrashNotice {
|
||||
/// `debug.crash.message`, a second after the keys went down.
|
||||
Message,
|
||||
/// `debug.crash.warning` with the seconds left.
|
||||
Warning(i32),
|
||||
/// "Manually triggered debug crash".
|
||||
Crash,
|
||||
}
|
||||
|
||||
/// `KeyboardHandler`'s state, with the down state of `keyDebugModifier`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DebugKeys {
|
||||
pub used_debug_key_as_modifier: bool,
|
||||
/// `debugCrashKeyTime`: when the crash keys went down (milliseconds),
|
||||
/// 0 never, -1 released.
|
||||
pub crash_key_time: i64,
|
||||
pub crash_key_reported_time: i64,
|
||||
pub crash_key_reported_count: i64,
|
||||
/// `keyDebugModifier.isDown()`.
|
||||
pub modifier_down: bool,
|
||||
}
|
||||
|
||||
impl DebugKeys {
|
||||
/// `keyPress` once the open screen (if any) did not take the key. `key`
|
||||
/// is the physical key's name (`key.keyboard.f3`); `crash_key_down`
|
||||
/// whether the crash mapping's key is held (`InputConstants.isKeyDown`).
|
||||
/// Returns whether the key goes on to the game's key mappings
|
||||
/// (`KeyMapping.set(key, true)` and `click`): only without a screen and
|
||||
/// when it ran no debug action.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn key_press(
|
||||
&mut self,
|
||||
host: &mut impl DebugHost,
|
||||
binds: &KeyBinds,
|
||||
key: &str,
|
||||
action: KeyAction,
|
||||
shift: bool,
|
||||
crash_key_down: bool,
|
||||
now: i64,
|
||||
) -> bool {
|
||||
let modifier = binds.get("key.debug.modifier");
|
||||
let overlay = binds.get("key.debug.overlay");
|
||||
let same = modifier == overlay;
|
||||
let modifier_down = self.modifier_down;
|
||||
let crash = binds.get("key.debug.crash") != "key.keyboard.unknown" && crash_key_down;
|
||||
if self.crash_key_time > 0 {
|
||||
if !crash || !modifier_down {
|
||||
self.crash_key_time = -1;
|
||||
}
|
||||
} else if crash && modifier_down {
|
||||
self.used_debug_key_as_modifier = same;
|
||||
self.crash_key_time = now;
|
||||
self.crash_key_reported_time = now;
|
||||
self.crash_key_reported_count = 0;
|
||||
}
|
||||
let screen = host.screen();
|
||||
let handles_game = screen == OpenScreen::None;
|
||||
let handles_global = handles_game
|
||||
|| matches!(
|
||||
screen,
|
||||
OpenScreen::PauseWithoutMenu | OpenScreen::GameModeSwitcher
|
||||
);
|
||||
if same && key == modifier && action == KeyAction::Release {
|
||||
if self.used_debug_key_as_modifier {
|
||||
self.used_debug_key_as_modifier = false;
|
||||
} else {
|
||||
host.toggle_overlay();
|
||||
}
|
||||
} else if !same && key == overlay && action == KeyAction::Press {
|
||||
host.toggle_overlay();
|
||||
}
|
||||
if action == KeyAction::Release {
|
||||
// KeyMapping.set(key, false).
|
||||
if key == modifier {
|
||||
self.modifier_down = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let mut did = false;
|
||||
if handles_global && key == "key.keyboard.escape" {
|
||||
host.pause_game(modifier_down);
|
||||
did = modifier_down;
|
||||
} else if modifier_down {
|
||||
did = self.handle_debug_keys(host, binds, key, shift, now);
|
||||
}
|
||||
if same {
|
||||
self.used_debug_key_as_modifier |= did;
|
||||
}
|
||||
// KeyMapping.set(key, !did) for the game, setDown(!did) behind a screen.
|
||||
if key == modifier {
|
||||
self.modifier_down = !did;
|
||||
}
|
||||
handles_game && !did
|
||||
}
|
||||
/// `handleDebugKeys`: every debug mapping on this key, in order.
|
||||
fn handle_debug_keys(
|
||||
&mut self,
|
||||
host: &mut impl DebugHost,
|
||||
binds: &KeyBinds,
|
||||
key: &str,
|
||||
shift: bool,
|
||||
now: i64,
|
||||
) -> bool {
|
||||
// Once the crash keys have been down a tenth of a second.
|
||||
if self.crash_key_time > 0 && self.crash_key_time < now - 100 {
|
||||
return true;
|
||||
}
|
||||
let mut did = false;
|
||||
for (name, action) in ACTIONS {
|
||||
if binds.get(name) == key {
|
||||
did |= host.debug_action(action, shift);
|
||||
}
|
||||
}
|
||||
did
|
||||
}
|
||||
/// `KeyboardHandler.tick`.
|
||||
pub fn tick(&mut self, now: i64) -> Option<CrashNotice> {
|
||||
if self.crash_key_time <= 0 {
|
||||
return None;
|
||||
}
|
||||
let remaining = 10000 - (now - self.crash_key_time);
|
||||
let reported = now - self.crash_key_reported_time;
|
||||
if remaining < 0 {
|
||||
return Some(CrashNotice::Crash);
|
||||
}
|
||||
if reported >= 1000 {
|
||||
let notice = if self.crash_key_reported_count == 0 {
|
||||
CrashNotice::Message
|
||||
} else {
|
||||
// Mth.ceil(remainingTime / 1000.0F).
|
||||
CrashNotice::Warning((remaining as f32 / 1000.0).ceil() as i32)
|
||||
};
|
||||
self.crash_key_reported_time = now;
|
||||
self.crash_key_reported_count += 1;
|
||||
return Some(notice);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Host {
|
||||
screen: Option<OpenScreen>,
|
||||
overlay: u32,
|
||||
paused: Vec<bool>,
|
||||
actions: Vec<(DebugAction, bool)>,
|
||||
/// Actions whose condition fails (no player, reduced debug info...).
|
||||
refused: Vec<DebugAction>,
|
||||
}
|
||||
impl DebugHost for Host {
|
||||
fn screen(&self) -> OpenScreen {
|
||||
self.screen.unwrap_or(OpenScreen::None)
|
||||
}
|
||||
fn toggle_overlay(&mut self) {
|
||||
self.overlay += 1;
|
||||
}
|
||||
fn pause_game(&mut self, without_menu: bool) {
|
||||
self.paused.push(without_menu);
|
||||
}
|
||||
fn debug_action(&mut self, action: DebugAction, shift: bool) -> bool {
|
||||
self.actions.push((action, shift));
|
||||
!self.refused.contains(&action)
|
||||
}
|
||||
}
|
||||
const F3: &str = "key.keyboard.f3";
|
||||
|
||||
fn press(k: &mut DebugKeys, h: &mut Host, b: &KeyBinds, key: &str, now: i64) -> bool {
|
||||
k.key_press(h, b, key, KeyAction::Press, false, false, now)
|
||||
}
|
||||
fn release(k: &mut DebugKeys, h: &mut Host, b: &KeyBinds, key: &str) -> bool {
|
||||
k.key_press(h, b, key, KeyAction::Release, false, false, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f3_alone_toggles_the_overlay_on_release() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
// The F3 press goes to the game's mappings (key.debug.* are down now).
|
||||
assert!(press(&mut k, &mut h, &b, F3, 0));
|
||||
assert!(k.modifier_down);
|
||||
assert_eq!(h.overlay, 0);
|
||||
assert!(!release(&mut k, &mut h, &b, F3));
|
||||
assert_eq!(h.overlay, 1);
|
||||
assert!(!k.modifier_down);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_combination_runs_its_action_and_keeps_the_overlay() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
// F3+G: the action, and G does not reach the game.
|
||||
assert!(!press(&mut k, &mut h, &b, "key.keyboard.g", 10));
|
||||
assert_eq!(h.actions, [(DebugAction::ShowChunkBorders, false)]);
|
||||
release(&mut k, &mut h, &b, "key.keyboard.g");
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 0);
|
||||
// The next F3 alone toggles again.
|
||||
press(&mut k, &mut h, &b, F3, 20);
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_copies_the_location_and_both_c_mappings_share_the_key() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
k.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Press,
|
||||
true,
|
||||
true,
|
||||
5,
|
||||
);
|
||||
assert_eq!(h.actions, [(DebugAction::CopyLocation, true)]);
|
||||
// The crash keys are down: from a tenth of a second on, debug keys
|
||||
// are swallowed.
|
||||
assert_eq!(k.crash_key_time, 5);
|
||||
k.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Repeat,
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
);
|
||||
assert_eq!(h.actions.len(), 2);
|
||||
assert!(!k.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Repeat,
|
||||
false,
|
||||
true,
|
||||
200
|
||||
));
|
||||
assert_eq!(h.actions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_crash_keys_warn_every_second_then_crash() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
k.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Press,
|
||||
false,
|
||||
true,
|
||||
1000,
|
||||
);
|
||||
assert_eq!(k.tick(1500), None);
|
||||
assert_eq!(k.tick(2000), Some(CrashNotice::Message));
|
||||
assert_eq!(k.tick(2999), None);
|
||||
assert_eq!(k.tick(3000), Some(CrashNotice::Warning(8)));
|
||||
assert_eq!(k.tick(4100), Some(CrashNotice::Warning(7)));
|
||||
assert_eq!(k.tick(11_000), Some(CrashNotice::Warning(0)));
|
||||
assert_eq!(k.tick(11_001), Some(CrashNotice::Crash));
|
||||
// Releasing either key stops it on the next key event.
|
||||
let mut k2 = DebugKeys::default();
|
||||
press(&mut k2, &mut h, &b, F3, 0);
|
||||
k2.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Press,
|
||||
false,
|
||||
true,
|
||||
1000,
|
||||
);
|
||||
k2.key_press(
|
||||
&mut h,
|
||||
&b,
|
||||
"key.keyboard.c",
|
||||
KeyAction::Release,
|
||||
false,
|
||||
false,
|
||||
1200,
|
||||
);
|
||||
assert_eq!(k2.crash_key_time, -1);
|
||||
assert_eq!(k2.tick(20_000), None);
|
||||
// The crash used F3 as a modifier: its release does not toggle the overlay.
|
||||
release(&mut k2, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_actions_do_not_count() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
h.refused.push(DebugAction::ShowHitboxes);
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
// F3+B without a player (or with reduced debug info): B reaches the
|
||||
// game and F3's release still toggles the overlay.
|
||||
assert!(press(&mut k, &mut h, &b, "key.keyboard.b", 1));
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_pauses_with_or_without_the_menu() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
assert!(press(&mut k, &mut h, &b, "key.keyboard.escape", 0));
|
||||
press(&mut k, &mut h, &b, F3, 1);
|
||||
assert!(!press(&mut k, &mut h, &b, "key.keyboard.escape", 2));
|
||||
assert_eq!(h.paused, [false, true]);
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 0);
|
||||
// Behind another screen, Escape is the screen's.
|
||||
h.screen = Some(OpenScreen::Other);
|
||||
press(&mut k, &mut h, &b, "key.keyboard.escape", 3);
|
||||
assert_eq!(h.paused.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behind_a_screen_combinations_work_but_keys_stay_with_the_screen() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
h.screen = Some(OpenScreen::Other);
|
||||
assert!(!press(&mut k, &mut h, &b, F3, 0));
|
||||
assert!(k.modifier_down);
|
||||
assert!(!press(&mut k, &mut h, &b, "key.keyboard.h", 1));
|
||||
assert_eq!(h.actions, [(DebugAction::AdvancedTooltips, false)]);
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_separate_overlay_key_toggles_on_press() {
|
||||
let (mut k, mut h, mut b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
b.set("key.debug.overlay", "key.keyboard.f7");
|
||||
press(&mut k, &mut h, &b, "key.keyboard.f7", 0);
|
||||
assert_eq!(h.overlay, 1);
|
||||
press(&mut k, &mut h, &b, F3, 1);
|
||||
release(&mut k, &mut h, &b, F3);
|
||||
assert_eq!(h.overlay, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebound_mappings_follow_their_keys() {
|
||||
let (mut k, mut h, mut b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
b.set("key.debug.showChunkBorders", "key.keyboard.k");
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
assert!(press(&mut k, &mut h, &b, "key.keyboard.g", 1));
|
||||
press(&mut k, &mut h, &b, "key.keyboard.k", 2);
|
||||
assert_eq!(h.actions, [(DebugAction::ShowChunkBorders, false)]);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ impl ItemEntity {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
@@ -268,6 +269,7 @@ impl ExperienceOrb {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
|
||||
@@ -14,7 +14,11 @@ struct Globals {
|
||||
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: array<vec4<f32>, 2>,
|
||||
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
|
||||
// y: Nether cardinal lighting.
|
||||
time: vec4<f32>,
|
||||
crumbling_sprite: vec4<f32>,
|
||||
// Selection outline parameters (read by terrain.wgsl).
|
||||
outline_params: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -33,9 +37,16 @@ struct Out {
|
||||
@location(6) @interpolate(flat) wrap: vec2<f32>,
|
||||
// Overlays 9 and 10: projection_from_position(gl_Position), the End portal's texProj0.
|
||||
@location(7) proj: vec4<f32>,
|
||||
@location(8) back_color: vec4<f32>,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
let l0 = normalize(vec3(0.2, 1.0, -0.7));
|
||||
let l1 = normalize(vec3(-0.2, select(1.0, -1.0, nether), 0.7));
|
||||
return min(1.0, (max(0.0, dot(l0, normal)) + max(0.0, dot(l1, normal))) * 0.6 + 0.4);
|
||||
}
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>) -> Out {
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
|
||||
var out: Out;
|
||||
out.clip = globals.view_proj * vec4(position, 1.0);
|
||||
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
|
||||
@@ -43,13 +54,22 @@ struct Out {
|
||||
let l = vec2(f32(light.y), f32(light.x));
|
||||
let lm = textureSampleLevel(lightmap, lightmap_sampler,
|
||||
clamp(l / 256.0 + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0);
|
||||
// overlay 2: celestial bodies, 4: clouds; both use their vertex colour unlit.
|
||||
var c = select(color * lm, color, overlay == 2u || overlay == 4u || overlay == 6u);
|
||||
// Celestial bodies, clouds, beacon beams and breaking cracks are unlit.
|
||||
var c = select(color * lm, color, overlay == 2u || overlay == 4u || overlay == 6u || overlay == 11u || overlay == 12u);
|
||||
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range.
|
||||
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
|
||||
var back = c;
|
||||
if normal.w != 0.0 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
}
|
||||
// Hurt overlay (OverlayTexture red tint).
|
||||
if overlay == 1u || overlay == 8u { c = vec4(mix(c.rgb, vec3(1.0, 0.0, 0.0), 0.3), c.a); }
|
||||
if overlay == 1u || overlay == 8u {
|
||||
c = vec4(mix(c.rgb, vec3(1.0, 0.0, 0.0), 0.3), c.a);
|
||||
back = vec4(mix(back.rgb, vec3(1.0, 0.0, 0.0), 0.3), back.a);
|
||||
}
|
||||
out.color = c;
|
||||
out.back_color = back;
|
||||
// Overlay 5: OverlayTexture white flash (primed TNT), applied after texturing.
|
||||
out.flash = select(0.0, 0.75, overlay == 5u);
|
||||
// Sky geometry (clouds, celestial bodies) is exempt from render-distance fog.
|
||||
@@ -99,10 +119,13 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d >= end { return 1.0; }
|
||||
return (d - start) / (end - start);
|
||||
}
|
||||
@fragment fn fs_entity(in: Out) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
var color = textureSample(atlas, atlas_sampler, uv) * in.color;
|
||||
if in.overlay == 11u || in.overlay == 12u {
|
||||
uv = globals.crumbling_sprite.xy + fract(in.uv) * globals.crumbling_sprite.zw;
|
||||
}
|
||||
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
|
||||
if in.overlay == 9u || in.overlay == 10u {
|
||||
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
|
||||
// for gateways) layers of end_portal.png projected from the screen.
|
||||
|
||||
@@ -156,6 +156,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
if m.kind.category == "creature" && aged {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
@@ -589,6 +590,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.custom_name = custom_name(tag);
|
||||
if m.kind.category == "creature" {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
}
|
||||
m.name_visible = flag(tag, "CustomNameVisible");
|
||||
@@ -1256,20 +1258,44 @@ fn villager_data(m: &Mob) -> Tag {
|
||||
}
|
||||
/// `AbstractVillager` "Offers" (`MerchantOffers.CODEC`).
|
||||
fn put_offers(map: &mut Compound, m: &Mob) {
|
||||
let Some(t) = &m.extra.trade else { return };
|
||||
let recipes = t
|
||||
.offers
|
||||
let Some(t) = m.extra.trade.as_ref().filter(|t| !t.unset) else {
|
||||
return;
|
||||
};
|
||||
map.insert("Offers".into(), offers_to_nbt(&t.offers));
|
||||
}
|
||||
/// `ItemCost.CODEC`: id, count (always written) and the exact components.
|
||||
fn item_cost_to_nbt(stack: &crate::items::ItemStack) -> Tag {
|
||||
let mut tag = item_to_nbt(stack);
|
||||
if let Tag::Compound(c) = &mut tag
|
||||
&& !c.contains_key(&"count".into())
|
||||
{
|
||||
c.insert("count".into(), Tag::Int(stack.count as i32));
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `MerchantOffers.CODEC`: `{Recipes: [...]}`, each offer without the
|
||||
/// fields at their defaults.
|
||||
pub fn offers_to_nbt(offers: &[crate::merchant::MerchantOffer]) -> Tag {
|
||||
let recipes = offers
|
||||
.iter()
|
||||
.map(|o| {
|
||||
let mut entries = vec![
|
||||
("buy", item_to_nbt(&o.base_cost_a.stack)),
|
||||
("buy", item_cost_to_nbt(&o.base_cost_a.stack)),
|
||||
("sell", item_to_nbt(&o.result)),
|
||||
("uses", Tag::Int(o.uses)),
|
||||
("maxUses", Tag::Int(o.max_uses)),
|
||||
("priceMultiplier", Tag::Float(o.price_multiplier.to_bits())),
|
||||
("xp", Tag::Int(o.xp)),
|
||||
];
|
||||
// `MerchantOffer.CODEC` omits fields at their defaults.
|
||||
if o.uses != 0 {
|
||||
entries.push(("uses", Tag::Int(o.uses)));
|
||||
}
|
||||
if o.max_uses != 4 {
|
||||
entries.push(("maxUses", Tag::Int(o.max_uses)));
|
||||
}
|
||||
if o.price_multiplier != 0.0 {
|
||||
entries.push(("priceMultiplier", Tag::Float(o.price_multiplier.to_bits())));
|
||||
}
|
||||
if o.xp != 1 {
|
||||
entries.push(("xp", Tag::Int(o.xp)));
|
||||
}
|
||||
if !o.reward_exp {
|
||||
entries.push(("rewardExp", Tag::Byte(0)));
|
||||
}
|
||||
@@ -1280,15 +1306,45 @@ fn put_offers(map: &mut Compound, m: &Mob) {
|
||||
entries.push(("demand", Tag::Int(o.demand)));
|
||||
}
|
||||
if let Some(b) = &o.cost_b {
|
||||
entries.push(("buyB", item_to_nbt(&b.stack)));
|
||||
entries.push(("buyB", item_cost_to_nbt(&b.stack)));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect();
|
||||
map.insert(
|
||||
"Offers".into(),
|
||||
Tag::compound([("Recipes", Tag::List(recipes))]),
|
||||
);
|
||||
.collect::<Vec<_>>();
|
||||
// An empty list is the field's default: left out.
|
||||
if recipes.is_empty() {
|
||||
return Tag::Compound(Compound::new());
|
||||
}
|
||||
Tag::compound([("Recipes", Tag::List(recipes))])
|
||||
}
|
||||
/// The offers of a `MerchantOffers.CODEC` tag.
|
||||
pub fn offers_from_nbt(tag: &Tag) -> Vec<crate::merchant::MerchantOffer> {
|
||||
use crate::merchant::{ItemCost, MerchantOffer};
|
||||
let Some(Tag::List(recipes)) = tag.get("Recipes") else {
|
||||
return Vec::new();
|
||||
};
|
||||
recipes
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let cost = |key| {
|
||||
r.get(key)
|
||||
.and_then(item_from_nbt)
|
||||
.map(|stack| ItemCost { stack })
|
||||
};
|
||||
Some(MerchantOffer {
|
||||
base_cost_a: cost("buy")?,
|
||||
cost_b: cost("buyB"),
|
||||
result: r.get("sell").and_then(item_from_nbt)?,
|
||||
uses: number(r.get("uses")).unwrap_or(0) as i32,
|
||||
max_uses: number(r.get("maxUses")).unwrap_or(4) as i32,
|
||||
reward_exp: number(r.get("rewardExp")).unwrap_or(1) != 0,
|
||||
special_price_diff: number(r.get("specialPrice")).unwrap_or(0) as i32,
|
||||
demand: number(r.get("demand")).unwrap_or(0) as i32,
|
||||
price_multiplier: float(r.get("priceMultiplier")).unwrap_or(0.0) as f32,
|
||||
xp: number(r.get("xp")).unwrap_or(1) as i32,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The villager brain's `job_site`, `home` and `meeting_point` memories.
|
||||
fn put_memories(map: &mut Compound, m: &Mob) {
|
||||
@@ -1314,7 +1370,7 @@ fn put_memories(map: &mut Compound, m: &Mob) {
|
||||
}
|
||||
fn read_villager(m: &mut Mob, tag: &Tag) {
|
||||
use crate::{
|
||||
merchant::{ItemCost, MerchantOffer, Trader},
|
||||
merchant::Trader,
|
||||
mobs::render::{PROFESSIONS, VILLAGER_TYPES},
|
||||
};
|
||||
if let Some(data) = tag.get("VillagerData") {
|
||||
@@ -1343,32 +1399,14 @@ fn read_villager(m: &mut Mob, tag: &Tag) {
|
||||
if m.species() == crate::mobs::Species::Villager {
|
||||
crate::mobs::villager::read_nbt(m, tag);
|
||||
}
|
||||
let Some(Tag::List(recipes)) = tag.get("Offers").and_then(|o| o.get("Recipes")) else {
|
||||
let offers_tag = tag.get("Offers");
|
||||
let xp = number(tag.get("Xp")).unwrap_or(0) as i32;
|
||||
if offers_tag.is_none() && xp == 0 && tag.get("LastRestock").is_none() {
|
||||
return;
|
||||
};
|
||||
let offers = recipes
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let cost = |key| {
|
||||
r.get(key)
|
||||
.and_then(item_from_nbt)
|
||||
.map(|stack| ItemCost { stack })
|
||||
};
|
||||
Some(MerchantOffer {
|
||||
base_cost_a: cost("buy")?,
|
||||
cost_b: cost("buyB"),
|
||||
result: r.get("sell").and_then(item_from_nbt)?,
|
||||
uses: number(r.get("uses")).unwrap_or(0) as i32,
|
||||
max_uses: number(r.get("maxUses")).unwrap_or(4) as i32,
|
||||
reward_exp: number(r.get("rewardExp")).unwrap_or(1) != 0,
|
||||
special_price_diff: number(r.get("specialPrice")).unwrap_or(0) as i32,
|
||||
demand: number(r.get("demand")).unwrap_or(0) as i32,
|
||||
price_multiplier: float(r.get("priceMultiplier")).unwrap_or(0.0) as f32,
|
||||
xp: number(r.get("xp")).unwrap_or(1) as i32,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let offers = offers_tag.map(offers_from_nbt).unwrap_or_default();
|
||||
m.extra.trade = Some(Box::new(Trader {
|
||||
unset: offers_tag.is_none(),
|
||||
xp: number(tag.get("Xp")).unwrap_or(0) as i32,
|
||||
offers,
|
||||
last_restock: number(tag.get("LastRestock")).unwrap_or(0),
|
||||
|
||||
@@ -139,6 +139,8 @@ pub struct Sky {
|
||||
/// Environmental fog (`visual/fog_start_distance` / `fog_end_distance`).
|
||||
pub fog_start: f32,
|
||||
pub fog_end: f32,
|
||||
/// `visual/cloud_fog_end_distance`.
|
||||
pub cloud_fog_end: f32,
|
||||
}
|
||||
impl Environment {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
@@ -299,6 +301,10 @@ impl Environment {
|
||||
.base
|
||||
.get("minecraft:visual/fog_end_distance")
|
||||
.map_or(1024.0, |v| v[0]),
|
||||
cloud_fog_end: self
|
||||
.base
|
||||
.get("minecraft:visual/cloud_fog_end_distance")
|
||||
.map_or(2048.0, |v| v[0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,35 @@ impl HandHeights {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LocalPlayer.xBob` and `yBob`: the hands lag behind the view when it
|
||||
/// turns. `LocalPlayer.applyInput` eases them halfway to the rotation every
|
||||
/// tick; `FirstPersonHandsAndItemsRenderer` turns the hands by a tenth of
|
||||
/// the gap left.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct HandSway {
|
||||
pub x_bob: f32,
|
||||
pub y_bob: f32,
|
||||
pub x_bob_o: f32,
|
||||
pub y_bob_o: f32,
|
||||
}
|
||||
impl HandSway {
|
||||
/// `LocalPlayer.applyInput` with the player's `getXRot` and `getYRot`.
|
||||
pub fn tick(&mut self, x_rot: f32, y_rot: f32) {
|
||||
self.y_bob_o = self.y_bob;
|
||||
self.x_bob_o = self.x_bob;
|
||||
self.x_bob += (x_rot - self.x_bob) * 0.5;
|
||||
self.y_bob += (y_rot - self.y_bob) * 0.5;
|
||||
}
|
||||
/// The degrees the hands turn about X, then Y, before either is drawn:
|
||||
/// `(viewXRot - xBob) * 0.1` and `(viewYRot - yBob) * 0.1`, the bobs
|
||||
/// interpolated as `extractRenderState` does.
|
||||
pub fn rotation(&self, partial: f32, view_x_rot: f32, view_y_rot: f32) -> [f32; 2] {
|
||||
let x_bob = crate::camera::lerp(partial, self.x_bob_o, self.x_bob);
|
||||
let y_bob = crate::camera::lerp(partial, self.y_bob_o, self.y_bob);
|
||||
[(view_x_rot - x_bob) * 0.1, (view_y_rot - y_bob) * 0.1]
|
||||
}
|
||||
}
|
||||
|
||||
/// `HitResult` at the crosshair (`Minecraft.hitResult`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Hit {
|
||||
|
||||
@@ -196,6 +196,8 @@ pub struct FogInputs<'a> {
|
||||
pub water_fog_color: [f32; 3],
|
||||
pub biome: u16,
|
||||
pub render_distance_chunks: i32,
|
||||
/// The Cloud Distance option, in chunks.
|
||||
pub cloud_range_chunks: i32,
|
||||
pub camera_y: f64,
|
||||
pub min_y: i32,
|
||||
/// Flat worlds use a 1-block void darkness range.
|
||||
@@ -335,7 +337,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
let min_end = sky.fog_end.min(96.0);
|
||||
fog.environmental_end = min_end.max(sky.fog_end + -256.0 * state.rain_fog_multiplier);
|
||||
fog.sky_end = rd.min(512.0);
|
||||
fog.cloud_end = 2048.0f32.min(128.0 * 16.0);
|
||||
fog.cloud_end = ((input.cloud_range_chunks * 16) as f32).min(sky.cloud_fog_end);
|
||||
}
|
||||
}
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
@@ -373,6 +375,7 @@ mod tests {
|
||||
water_fog_color: [0.0, 0.0, 0.2],
|
||||
biome: 0,
|
||||
render_distance_chunks: 12,
|
||||
cloud_range_chunks: 128,
|
||||
camera_y: 70.0,
|
||||
min_y: -64,
|
||||
flat: false,
|
||||
@@ -402,6 +405,29 @@ mod tests {
|
||||
assert_eq!(fog.sky_end, 192.0);
|
||||
}
|
||||
#[test]
|
||||
fn cloud_fog_ends_at_the_cloud_distance() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
cloud_fog_end: 2048.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.render_distance_chunks = 2;
|
||||
input.cloud_range_chunks = 64;
|
||||
assert_eq!(
|
||||
setup_fog(&tables, &FogState::default(), &input).cloud_end,
|
||||
1024.0
|
||||
);
|
||||
input.cloud_range_chunks = 128;
|
||||
assert_eq!(
|
||||
setup_fog(&tables, &FogState::default(), &input).cloud_end,
|
||||
2048.0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn water_vision_ramps() {
|
||||
let mut s = FogState::default();
|
||||
for _ in 0..100 {
|
||||
|
||||
@@ -163,6 +163,17 @@ fn providers(root: &Path, id: &str, depth: u32, out: &mut Vec<serde_json::Value>
|
||||
}
|
||||
}
|
||||
}
|
||||
// FontTexture.add nudges UVs inside the glyph to avoid neighboring texels.
|
||||
fn glyph_rect(x: u32, y: u32, w: u32, h: u32, atlas: (u32, u32)) -> Rect {
|
||||
Rect {
|
||||
u0: (x as f32 + 0.01) / atlas.0 as f32,
|
||||
v0: (y as f32 + 0.01) / atlas.1 as f32,
|
||||
u1: ((x + w) as f32 - 0.01) / atlas.0 as f32,
|
||||
v1: ((y + h) as f32 - 0.01) / atlas.1 as f32,
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
impl Font {
|
||||
/// Build from the atlas holding the bitmap font sheets. `pixels` is the
|
||||
/// RGBA atlas (for glyph widths) and `region` the reserved rows.
|
||||
@@ -237,14 +248,7 @@ impl Font {
|
||||
bitmap.insert(
|
||||
*ch,
|
||||
Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: cx as f32 / atlas.0 as f32,
|
||||
v0: cy as f32 / atlas.1 as f32,
|
||||
u1: (cx + cell_w) as f32 / atlas.0 as f32,
|
||||
v1: (cy + cell_h) as f32 / atlas.1 as f32,
|
||||
w: cell_w,
|
||||
h: cell_h,
|
||||
}),
|
||||
rect: Some(glyph_rect(cx, cy, cell_w, cell_h, atlas)),
|
||||
left: 0.0,
|
||||
top,
|
||||
right: cell_w as f32 * scale,
|
||||
@@ -414,14 +418,7 @@ impl Font {
|
||||
});
|
||||
cache.cursor = (x + width + 1, y);
|
||||
Some(Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: ax as f32 / self.atlas.0 as f32,
|
||||
v0: ay as f32 / self.atlas.1 as f32,
|
||||
u1: (ax + width) as f32 / self.atlas.0 as f32,
|
||||
v1: (ay + 16) as f32 / self.atlas.1 as f32,
|
||||
w: width,
|
||||
h: 16,
|
||||
}),
|
||||
rect: Some(glyph_rect(ax, ay, width, 16, self.atlas)),
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: width as f32 / 2.0,
|
||||
@@ -609,4 +606,16 @@ mod tests {
|
||||
parse_hex(b"zz:00\n", &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_uvs_have_the_native_texel_inset() {
|
||||
for (x, y, w, h) in [(120, 48, 8, 8), (0, 0, 1, 16), (4000, 2000, 32, 16)] {
|
||||
let r = glyph_rect(x, y, w, h, (4096, 4096));
|
||||
assert_eq!((r.w, r.h), (w, h));
|
||||
assert!(r.u0 > x as f32 / 4096.0 && r.v0 > y as f32 / 4096.0);
|
||||
assert!(r.u1 < (x + w) as f32 / 4096.0 && r.v1 < (y + h) as f32 / 4096.0);
|
||||
assert_eq!(r.u0, (x as f32 + 0.01) / 4096.0);
|
||||
assert_eq!(r.v1, ((y + h) as f32 - 0.01) / 4096.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+713
-342
File diff suppressed because it is too large.
Load diff
@@ -338,6 +338,10 @@ impl Game<'_> {
|
||||
baby: m.is_baby(),
|
||||
on_ground: m.on_ground,
|
||||
equipment,
|
||||
components: horizon_client::mobs::finalize::variant_component(m)
|
||||
.map(|(id, value)| (id.to_owned(), value))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -577,6 +581,28 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// `EnterBlockTrigger`: the block, its state properties and the player.
|
||||
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
|
||||
let Some(definition) = self.res.assets.registry.state(state).cloned() else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:enter_block", move |c, w| {
|
||||
let block_ok = adv::id_matches(&c["blocks"], "block", &definition.name, w);
|
||||
let state_ok = c["state"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|(k, v)| definition.properties.get(k).map(String::as_str) == v.as_str());
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
block_ok && state_ok && adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
|
||||
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -159,6 +159,7 @@ impl Game<'_> {
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(bounds).unwrap_or(false)
|
||||
|| self
|
||||
@@ -362,6 +363,7 @@ impl Game<'_> {
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
for a in &mut self.armor_stands {
|
||||
a.tick(&mut scene);
|
||||
|
||||
@@ -142,6 +142,7 @@ impl Game<'_> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
no_collision_empty(
|
||||
&mut scene,
|
||||
|
||||
@@ -318,6 +318,7 @@ impl Game<'_> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if b.old_status == Status::InAir && b.status != Status::InAir && b.status != Status::OnLand
|
||||
{
|
||||
@@ -583,6 +584,7 @@ impl Game<'_> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) {
|
||||
return false;
|
||||
|
||||
@@ -642,14 +642,13 @@ impl Game<'_> {
|
||||
self.build_registries();
|
||||
self.sync_rules();
|
||||
}
|
||||
/// Save game rules, the clock and the difficulty beside level.dat.
|
||||
/// Save the clock and the difficulty beside level.dat (Level.write saves
|
||||
/// the game rules: a second writer of game_rules.dat could leave it with
|
||||
/// the rules loaded when the world opened).
|
||||
pub(super) fn save_world_state(&mut self) {
|
||||
if self.world_root.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.ui.rules.save(&self.world_root) {
|
||||
eprintln!("Horizon: failed to save game rules: {e}");
|
||||
}
|
||||
use horizon_world::nbt::Tag;
|
||||
let c = self.ui.clock;
|
||||
// The other clocks stay as saved; the overworld one keeps the fields Horizon
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Command execution (the `execute` side of each vanilla command class):
|
||||
//! entity selectors resolved against the game, vanilla feedback and errors.
|
||||
mod compute;
|
||||
pub(super) mod compute;
|
||||
mod data;
|
||||
mod effects;
|
||||
pub(super) mod execute;
|
||||
@@ -705,7 +705,7 @@ impl Game<'_> {
|
||||
_ => Ok(0),
|
||||
}
|
||||
}
|
||||
fn set_game_mode(&mut self, mode: GameMode) -> bool {
|
||||
pub(super) fn set_game_mode(&mut self, mode: GameMode) -> bool {
|
||||
if self.mode == mode {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -954,16 +954,11 @@ impl Game<'_> {
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `VersionCommand`: Horizon reports the version it implements.
|
||||
pub(super) fn cmd_version(&mut self) -> R<i32> {
|
||||
let send = |g: &mut Self, t: Text| {
|
||||
if !g.command_source.is_some_and(|s| s.silent) {
|
||||
g.system_message(t);
|
||||
}
|
||||
};
|
||||
send(self, Text::translate("commands.version.header", &[]));
|
||||
/// `VersionCommand.dumpVersion`'s lines: Horizon reports the version it
|
||||
/// implements.
|
||||
pub(in crate::game) fn version_lines(&self) -> Vec<Text> {
|
||||
let protocol = horizon_protocol::PROTOCOL_VERSION;
|
||||
for (key, args) in [
|
||||
let mut lines: Vec<Text> = [
|
||||
("commands.version.id", vec![Text::literal(VERSION_ID)]),
|
||||
("commands.version.name", vec![Text::literal(VERSION_ID)]),
|
||||
("commands.version.data", vec![num(DATA_VERSION)]),
|
||||
@@ -981,20 +976,28 @@ impl Game<'_> {
|
||||
vec![Text::literal("97.1")],
|
||||
),
|
||||
("commands.version.pack.data", vec![Text::literal("121.0")]),
|
||||
] {
|
||||
send(self, Text::translate(key, &args));
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(key, args)| Text::translate(key, &args))
|
||||
.collect();
|
||||
lines.push(Text::translate(
|
||||
if STABLE {
|
||||
"commands.version.stable.yes"
|
||||
} else {
|
||||
"commands.version.stable.no"
|
||||
},
|
||||
&[],
|
||||
));
|
||||
lines
|
||||
}
|
||||
/// `VersionCommand`.
|
||||
pub(super) fn cmd_version(&mut self) -> R<i32> {
|
||||
if !self.command_source.is_some_and(|s| s.silent) {
|
||||
self.system_message(Text::translate("commands.version.header", &[]));
|
||||
for line in self.version_lines() {
|
||||
self.system_message(line);
|
||||
}
|
||||
}
|
||||
send(
|
||||
self,
|
||||
Text::translate(
|
||||
if STABLE {
|
||||
"commands.version.stable.yes"
|
||||
} else {
|
||||
"commands.version.stable.no"
|
||||
},
|
||||
&[],
|
||||
),
|
||||
);
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +418,30 @@ impl Game<'_> {
|
||||
.unwrap_or(1.0)
|
||||
.clamp(0.01, 4.0),
|
||||
),
|
||||
ParticleType::Block | ParticleType::FallingDust | ParticleType::DustPillar => {
|
||||
// ItemParticleOption: an item id, or a stack {id, ...}.
|
||||
ParticleType::Item => {
|
||||
let id = match field("item") {
|
||||
Some(Tag::String(s)) => s.to_string_lossy(),
|
||||
Some(Tag::Compound(c)) => {
|
||||
c.get(&"id".into()).and_then(string).unwrap_or_default()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let id = if id.contains(':') {
|
||||
id
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
self.item_particle(&horizon_client::items::ItemStack::new(&id, 1))
|
||||
}
|
||||
ParticleType::ItemSlime | ParticleType::ItemSnowball | ParticleType::ItemCobweb => {
|
||||
self.ensure_item_particle_sprites();
|
||||
ParticleOptions::simple(ty)
|
||||
}
|
||||
ParticleType::Block
|
||||
| ParticleType::FallingDust
|
||||
| ParticleType::DustPillar
|
||||
| ParticleType::BlockMarker => {
|
||||
// BlockParticleOption: a block id, or {Name, Properties}.
|
||||
let text = match field("block_state") {
|
||||
Some(Tag::String(s)) => s.to_string_lossy(),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! `ComposterBlock`: compostable items go in by hand (`useItemOn`) or
|
||||
//! through its input container (hoppers, droppers, crafters above), and the
|
||||
//! fill feedback of level event 1500 (`handleFill`). Emptying a full
|
||||
//! composter by hand is `useWithoutItem` in horizon-world.
|
||||
use super::{Game, commands::compute::ComputeContext};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The composter's `level` at `pos`, None when it isn't a composter.
|
||||
pub(super) fn composter_level(&self, pos: [i32; 3]) -> Option<i32> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let s = self.res.assets.registry.state(state)?;
|
||||
if &*s.name != "minecraft:composter" {
|
||||
return None;
|
||||
}
|
||||
s.properties.get("level")?.parse().ok()
|
||||
}
|
||||
/// The item's `compostable` layers provider.
|
||||
fn compostable(&self, stack: &ItemStack) -> Option<serde_json::Value> {
|
||||
self.res.items.get(&stack.item)?.compostable.clone()
|
||||
}
|
||||
/// `ComposterBlock.addLayer` and level event 1500: the provider is
|
||||
/// evaluated in the composter's block-interaction loot context (its
|
||||
/// state; the level random), then the layer is added. True when it was.
|
||||
fn composter_add(&mut self, pos: [i32; 3], layers: &serde_json::Value) -> bool {
|
||||
let context = ComputeContext {
|
||||
block: Some(pos),
|
||||
target: None,
|
||||
};
|
||||
let layers = self.provider_int(layers, &context, 0).unwrap_or(0);
|
||||
let before = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let after = self.with_level(|b, level, ticks| b.composter_insert(level, ticks, at, layers));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
// The game event and level event 1500 happen here, before the caller goes on.
|
||||
let _ = self.process_level_events();
|
||||
after != before
|
||||
}
|
||||
/// `ComposterBlock.useItemOn` with the main-hand stack: true when it
|
||||
/// consumed the action (SUCCESS; a full composter falls through to
|
||||
/// `useWithoutItem`, which empties it).
|
||||
pub(super) fn use_composter(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let Some(level) = self.composter_level(pos) else {
|
||||
return false;
|
||||
};
|
||||
let Some(stack) = held else {
|
||||
return false;
|
||||
};
|
||||
let Some(layers) = self.compostable(stack) else {
|
||||
return false;
|
||||
};
|
||||
if level >= 8 {
|
||||
return false;
|
||||
}
|
||||
if level < 7 {
|
||||
self.composter_add(pos, &layers);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// `HopperBlockEntity.addItem` into the composter's `InputContainer`
|
||||
/// (levels 0 to 6; one item, compostable, from above): the remainder.
|
||||
pub(super) fn composter_container_insert(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
mut stack: ItemStack,
|
||||
from_above: bool,
|
||||
) -> Option<ItemStack> {
|
||||
let level = self.composter_level(pos)?;
|
||||
if !from_above || level >= 7 {
|
||||
return Some(stack);
|
||||
}
|
||||
let Some(layers) = self.compostable(&stack) else {
|
||||
return Some(stack);
|
||||
};
|
||||
// InputContainer.getMaxStackSize is 1: one item goes in, and its
|
||||
// setChanged composts it at once.
|
||||
stack.count -= 1;
|
||||
self.composter_add(pos, &layers);
|
||||
(stack.count > 0).then_some(stack)
|
||||
}
|
||||
/// `ComposterBlock.handleFill` (level event 1500) on the client: the
|
||||
/// fill sound and ten composter particles above the compost.
|
||||
pub(super) fn composter_fill_event(&mut self, pos: BlockPos, success: bool) {
|
||||
let center = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
let sound = if success {
|
||||
"minecraft:block.composter.fill_success"
|
||||
} else {
|
||||
"minecraft:block.composter.fill"
|
||||
};
|
||||
self.audio.play(sound, Some(center), 1.0, 1.0);
|
||||
// getShape(..).max(Y, 0.5, 0.5): the compost surface (SHAPES[8] = SHAPES[7]).
|
||||
let level = self.composter_level([pos.x, pos.y, pos.z]).unwrap_or(0);
|
||||
let height = f64::from((1 + level.min(7) * 2).clamp(2, 16)) / 16.0 + 0.03125;
|
||||
let options = ParticleOptions::simple(ParticleType::Composter);
|
||||
for _ in 0..10 {
|
||||
let r = self.particles.random();
|
||||
let v = [
|
||||
r.next_gaussian() * 0.02,
|
||||
r.next_gaussian() * 0.02,
|
||||
r.next_gaussian() * 0.02,
|
||||
];
|
||||
let p = [
|
||||
f64::from(pos.x) + 0.1875 + 0.625 * f64::from(r.next_float()),
|
||||
f64::from(pos.y) + height + f64::from(r.next_float()) * (1.0 - height),
|
||||
f64::from(pos.z) + 0.1875 + 0.625 * f64::from(r.next_float()),
|
||||
];
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,8 @@ impl Game<'_> {
|
||||
self.pending_bench = Some((deadline, reply));
|
||||
}
|
||||
}
|
||||
horizon_client::widgets::end_frame(self.frames);
|
||||
self.advance_agent();
|
||||
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
|
||||
let reply = match self.control_command(&request.line, &request.reply, event_loop) {
|
||||
Ok(Some(value)) => value,
|
||||
@@ -118,6 +120,11 @@ impl Game<'_> {
|
||||
if let Some(value) = self.lan_control(&words)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
match self.agent_command(&words, line, reply, event_loop)? {
|
||||
super::agent::Handled::Reply(value) => return Ok(Some(value)),
|
||||
super::agent::Handled::Deferred => return Ok(None),
|
||||
super::agent::Handled::No => {}
|
||||
}
|
||||
match words[0] {
|
||||
"help" => Ok(Some(json!({ "commands": HELP
|
||||
.iter()
|
||||
@@ -126,6 +133,7 @@ impl Game<'_> {
|
||||
.chain(super::lan::HELP)
|
||||
.chain(super::render_control::HELP)
|
||||
.chain(super::sculk::HELP)
|
||||
.chain(super::agent::HELP)
|
||||
.collect::<Vec<_>>() }))),
|
||||
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
@@ -133,6 +141,26 @@ impl Game<'_> {
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
self.require_world()?;
|
||||
// KeyboardHandler.keyPress: the debug keys see the physical key first.
|
||||
let physical = key;
|
||||
{
|
||||
use horizon_client::debug_keys::KeyAction;
|
||||
let action = arg(2)?;
|
||||
if action != "up" {
|
||||
self.physical_keys.insert(key);
|
||||
if self.debug_key(key, KeyAction::Press) {
|
||||
if action == "tap" {
|
||||
self.physical_keys.remove(&key);
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
if action == "up" {
|
||||
self.physical_keys.remove(&key);
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
}
|
||||
// Physical key presses follow the key binds like the keyboard does.
|
||||
let key = if matches!(self.screen, Screen::Chat | Screen::Options) {
|
||||
key
|
||||
@@ -158,10 +186,13 @@ impl Game<'_> {
|
||||
"down" => self.control_key_down(key)?,
|
||||
"up" => {
|
||||
self.keys.remove(&key);
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
"tap" => {
|
||||
self.control_key_down(key)?;
|
||||
self.keys.remove(&key);
|
||||
self.physical_keys.remove(&physical);
|
||||
self.debug_key(physical, horizon_client::debug_keys::KeyAction::Release);
|
||||
}
|
||||
other => {
|
||||
return Err(
|
||||
@@ -173,6 +204,7 @@ impl Game<'_> {
|
||||
}
|
||||
"release" => {
|
||||
self.keys.clear();
|
||||
self.physical_keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
ok()
|
||||
@@ -339,9 +371,13 @@ impl Game<'_> {
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
self.creative_screen.search.push_str(text);
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap) or the language search".into(),
|
||||
"type needs the chat screen (key t tap), the language search or the creative search".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -468,14 +504,7 @@ impl Game<'_> {
|
||||
ok()
|
||||
}
|
||||
"quit" => {
|
||||
if self.title.is_none() {
|
||||
// Player.closeContainer: menu items go back before saving.
|
||||
self.close_container();
|
||||
if self.remote.is_none() {
|
||||
self.stats.custom("leave_game", 1);
|
||||
}
|
||||
self.save_game()?;
|
||||
}
|
||||
self.save_for_exit()?;
|
||||
event_loop.exit();
|
||||
ok()
|
||||
}
|
||||
@@ -494,6 +523,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn control_key_down(&mut self, key: KeyCode) -> Result<()> {
|
||||
self.physical_keys.insert(key);
|
||||
if self.screen == Screen::Chat {
|
||||
self.chat_key_code(key, None);
|
||||
return Ok(());
|
||||
@@ -552,6 +582,15 @@ impl Game<'_> {
|
||||
});
|
||||
json!({
|
||||
"screen": self.screen_name(),
|
||||
"debug": {
|
||||
"overlay": self.debug,
|
||||
"hitboxes": self.show_hitboxes,
|
||||
"chunk_borders": self.show_chunk_borders,
|
||||
"pause_menu": self.pause_menu,
|
||||
"advanced_tooltips": self.settings.file.bool("advancedItemTooltips"),
|
||||
"pause_on_lost_focus": self.settings.file.bool("pauseOnLostFocus"),
|
||||
"clipboard": self.copied,
|
||||
},
|
||||
"dimension": self.dim.id(),
|
||||
"creative": self.creative,
|
||||
"player": player,
|
||||
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::{
|
||||
Damage, DamageKind, Mob, MobEvent, MobWorld, PlayerView, Seen, SpawnSetup, Species, Who,
|
||||
ai::{eating_sound, feed_animal},
|
||||
farm::Climate,
|
||||
spawning::{self, BiomeFacts},
|
||||
},
|
||||
@@ -46,6 +47,7 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.w.world,
|
||||
binding: self.w.collision,
|
||||
subject: None,
|
||||
};
|
||||
let r = traverse_blocks(from, to, 256, |p: BlockPos| {
|
||||
let state = self.w.world.block(p.x, p.y, p.z);
|
||||
@@ -505,6 +507,7 @@ impl Game<'_> {
|
||||
self.tick_skeleton_traps();
|
||||
self.tick_apparition();
|
||||
self.villager_reputation_tick();
|
||||
self.merchant_tick();
|
||||
self.villager_pickups();
|
||||
for m in dead {
|
||||
self.mob_died(&m);
|
||||
@@ -1843,6 +1846,9 @@ impl Game<'_> {
|
||||
let mut consume = false;
|
||||
let mut give: Option<&str> = None;
|
||||
let mut sound: Option<&str> = None;
|
||||
// Sounds played as vanilla plays them: (event, volume, pitch) at the feet.
|
||||
let mut eat: Option<(&'static str, f32, f32)> = None;
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let mut handled = true;
|
||||
let mut drops: Vec<(String, u32)> = Vec::new();
|
||||
let is_food = m.kind.food.contains(&item);
|
||||
@@ -1918,9 +1924,9 @@ impl Game<'_> {
|
||||
.get(&item)
|
||||
.and_then(|i| i.food.as_ref())
|
||||
.map_or(2.0, |f| f.0 as f32);
|
||||
// TamableAnimal.feed: playEatingSound, which wolves leave empty.
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
consume = true;
|
||||
sound = Some("entity.wolf.eat");
|
||||
}
|
||||
Species::Wolf
|
||||
if m.extra.tame
|
||||
@@ -2022,9 +2028,10 @@ impl Game<'_> {
|
||||
.get(&item)
|
||||
.and_then(|i| i.food.as_ref())
|
||||
.map_or(1.0, |f| f.0 as f32);
|
||||
// TamableAnimal.feed.
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
consume = true;
|
||||
sound = Some("entity.cat.eat");
|
||||
eat = eating_sound(m, &mut self.world.random);
|
||||
}
|
||||
Species::Cat
|
||||
if m.extra.tame && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
|
||||
@@ -2037,7 +2044,6 @@ impl Game<'_> {
|
||||
}
|
||||
Species::Cat if !m.extra.tame && is_food => {
|
||||
consume = true;
|
||||
sound = Some("entity.cat.eat");
|
||||
m.persistent = true;
|
||||
let at = [
|
||||
m.position.x,
|
||||
@@ -2050,6 +2056,7 @@ impl Game<'_> {
|
||||
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
m.extra.sitting = true;
|
||||
}
|
||||
eat = eating_sound(m, &mut self.world.random);
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
self.handle_mob_events(vec![MobEvent::Particles { kind, at, count: 7 }])?;
|
||||
if tamed {
|
||||
@@ -2075,25 +2082,45 @@ impl Game<'_> {
|
||||
sound = Some("item.flintandsteel.use");
|
||||
self.damage_held_tool();
|
||||
}
|
||||
// Parrot.mobInteract: parrot food may tame a wild one (one time in
|
||||
// ten); a parrot never takes it as Animal food (isFood is false).
|
||||
Species::Parrot if !m.extra.tame && is_food => {
|
||||
consume = true;
|
||||
if !m.silent {
|
||||
let pitch = 1.0 + (m.random.next_float() - m.random.next_float()) * 0.2;
|
||||
eat = Some(("entity.parrot.eat", 1.0, pitch));
|
||||
}
|
||||
let at = [
|
||||
m.position.x,
|
||||
m.position.y + f64::from(m.height()) * 0.5 + 0.5,
|
||||
m.position.z,
|
||||
];
|
||||
let tamed = m.random.next_int_bound(10).unwrap_or(1) == 0;
|
||||
if tamed {
|
||||
m.extra.tame = true;
|
||||
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
}
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
self.handle_mob_events(vec![MobEvent::Particles { kind, at, count: 7 }])?;
|
||||
if tamed {
|
||||
let view = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_entity("minecraft:tame_animal", "entity", &view);
|
||||
}
|
||||
}
|
||||
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
|
||||
_ if is_food
|
||||
&& m.kind.flags.animal
|
||||
&& species != Species::Parrot
|
||||
&& (species != Species::HappyGhast || m.is_baby()) =>
|
||||
{
|
||||
// Animal.mobInteract: breed adults, speed up babies.
|
||||
if m.extra.age == 0 && m.extra.in_love <= 0 {
|
||||
m.extra.in_love = 600;
|
||||
m.extra.love_player = true;
|
||||
consume = true;
|
||||
sound = Some(eat_sound(species));
|
||||
} else if m.extra.age < 0 {
|
||||
// AgeableMob.ageUp(getSpeedUpSecondsWhenFeeding(-age)) = 10%.
|
||||
let secs = (-m.extra.age / 20) as f32 * 0.1;
|
||||
m.extra.age = (m.extra.age + (secs as i32) * 20).min(0);
|
||||
consume = true;
|
||||
sound = Some(eat_sound(species));
|
||||
} else {
|
||||
handled = false;
|
||||
// Animal.mobInteract: breed adults, speed up babies, then
|
||||
// playEatingSound (silent unless the species overrides it).
|
||||
match feed_animal(m, &mut self.world.random) {
|
||||
Some(sound) => {
|
||||
consume = true;
|
||||
eat = sound;
|
||||
}
|
||||
None => handled = false,
|
||||
}
|
||||
}
|
||||
_ => handled = false,
|
||||
@@ -2108,6 +2135,10 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(center), 1.0, pitch);
|
||||
}
|
||||
if let Some((s, volume, pitch)) = eat {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(feet), volume, pitch);
|
||||
}
|
||||
if consume
|
||||
&& !creative
|
||||
&& let Some(stack) = self.inventory[selected].as_mut()
|
||||
@@ -2407,6 +2438,7 @@ impl Game<'_> {
|
||||
.zip([[r.u0, v1], [u1, v1], [u1, r.v0], [r.u0, r.v0]])
|
||||
{
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -2472,6 +2504,7 @@ impl Game<'_> {
|
||||
{
|
||||
let v = c + right * k.0 * s + up * k.1 * s;
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -2482,19 +2515,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn eat_sound(s: Species) -> &'static str {
|
||||
match s {
|
||||
Species::Pig => "entity.pig.eat",
|
||||
Species::Horse | Species::Donkey | Species::Mule => "entity.horse.eat",
|
||||
Species::Llama | Species::TraderLlama => "entity.llama.eat",
|
||||
Species::Goat => "entity.goat.eat",
|
||||
Species::Sniffer => "entity.sniffer.eat",
|
||||
Species::Fox => "entity.fox.eat",
|
||||
Species::Cat => "entity.cat.eat",
|
||||
Species::Panda => "entity.panda.eat",
|
||||
_ => "entity.generic.eat",
|
||||
}
|
||||
}
|
||||
/// Mob.getBaseExperienceReward
|
||||
/// The damage type id of a Horizon damage kind.
|
||||
fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
//! The debug keys in the game (26.3 `KeyboardHandler.keyPress`,
|
||||
//! `handleDebugKeys` and `tick`): F3 combinations, F3+Escape, the held
|
||||
//! debug-crash keys, and the debug renderers they switch on.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
command::args::GameMode,
|
||||
debug_keys::{CrashNotice, DebugAction, DebugHost, KeyAction, OpenScreen},
|
||||
gizmos::Gizmos,
|
||||
keybinds,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Axis;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `ChatFormatting.YELLOW` and `RED`.
|
||||
const YELLOW: u32 = 0xFF_FF55;
|
||||
const RED: u32 = 0xFF_5555;
|
||||
|
||||
/// The game as `KeyboardHandler` sees it while it runs one key event.
|
||||
struct Host<'g, 'a> {
|
||||
game: &'g mut Game<'a>,
|
||||
}
|
||||
|
||||
impl DebugHost for Host<'_, '_> {
|
||||
fn screen(&self) -> OpenScreen {
|
||||
self.game.open_screen()
|
||||
}
|
||||
fn toggle_overlay(&mut self) {
|
||||
self.game.debug = !self.game.debug;
|
||||
}
|
||||
fn pause_game(&mut self, without_menu: bool) {
|
||||
// Gui.setPauseScreen: PauseScreen(false) only when the game really
|
||||
// pauses (single player, not open to the network). Escape alone opens
|
||||
// the menu as it always has (key_pressed).
|
||||
let g = &mut *self.game;
|
||||
if without_menu && g.screen == Screen::Playing {
|
||||
g.pause_menu = !(g.remote.is_none() && g.lan.is_none());
|
||||
g.screen = Screen::Paused;
|
||||
g.capture(false);
|
||||
g.mining = None;
|
||||
}
|
||||
}
|
||||
fn debug_action(&mut self, action: DebugAction, shift: bool) -> bool {
|
||||
self.game.run_debug_action(action, shift)
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn open_screen(&self) -> OpenScreen {
|
||||
match self.screen {
|
||||
Screen::Playing if self.book.is_none() && self.sign_edit.is_none() => OpenScreen::None,
|
||||
Screen::Paused if !self.pause_menu => OpenScreen::PauseWithoutMenu,
|
||||
_ => OpenScreen::Other,
|
||||
}
|
||||
}
|
||||
/// A physical key event (already in `physical_keys`) before the game's
|
||||
/// own handling. Returns true when it is used up (a debug combination,
|
||||
/// F3 itself, F3+Escape).
|
||||
pub(super) fn debug_key(&mut self, key: KeyCode, action: KeyAction) -> bool {
|
||||
let Some(name) = keybinds::key_name(key) else {
|
||||
return false;
|
||||
};
|
||||
let pressed = action != KeyAction::Release;
|
||||
// Screens with a text box take every key press first
|
||||
// (EditBox.keyPressed); releases still reach KeyboardHandler.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::WorldOptions
|
||||
) || self.sign_edit.is_some()
|
||||
|| self.book.is_some()
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
|| self.recipe_search_focused()
|
||||
|| self.station_typing()
|
||||
|| self.remote_chat_open();
|
||||
// Container screens use Escape, the inventory key and the hotbar and
|
||||
// drop keys first; any other screen closes on Escape.
|
||||
let screen_key = match self.screen {
|
||||
Screen::Inventory | Screen::Creative => matches!(
|
||||
key,
|
||||
KeyCode::Escape
|
||||
| KeyCode::KeyE
|
||||
| KeyCode::KeyF
|
||||
| KeyCode::KeyQ
|
||||
| KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
| KeyCode::Digit3
|
||||
| KeyCode::Digit4
|
||||
| KeyCode::Digit5
|
||||
| KeyCode::Digit6
|
||||
| KeyCode::Digit7
|
||||
| KeyCode::Digit8
|
||||
| KeyCode::Digit9
|
||||
),
|
||||
Screen::Playing => false,
|
||||
_ => key == KeyCode::Escape,
|
||||
};
|
||||
if pressed && (typing || screen_key) {
|
||||
return false;
|
||||
}
|
||||
let crash_down = keybinds::key_code(self.settings.keys.get("key.debug.crash"))
|
||||
.is_some_and(|k| self.physical_keys.contains(&k));
|
||||
let shift = self.physical_keys.contains(&KeyCode::ShiftLeft)
|
||||
|| self.physical_keys.contains(&KeyCode::ShiftRight);
|
||||
let now = self.debug_millis();
|
||||
let binds = self.settings.keys.clone();
|
||||
let mut keys = std::mem::take(&mut self.debug_keys);
|
||||
let modifier_was_down = keys.modifier_down;
|
||||
let to_game = keys.key_press(
|
||||
&mut Host { game: self },
|
||||
&binds,
|
||||
name,
|
||||
action,
|
||||
shift,
|
||||
crash_down,
|
||||
now,
|
||||
);
|
||||
self.debug_keys = keys;
|
||||
if !pressed {
|
||||
return false;
|
||||
}
|
||||
// The debug modifier does nothing else.
|
||||
if name == binds.get("key.debug.modifier") || name == binds.get("key.debug.overlay") {
|
||||
return true;
|
||||
}
|
||||
if self.open_screen() == OpenScreen::None {
|
||||
!to_game
|
||||
} else {
|
||||
modifier_was_down
|
||||
}
|
||||
}
|
||||
/// `Util.getMillis` (positive: KeyboardHandler treats 0 as unset).
|
||||
fn debug_millis(&self) -> i64 {
|
||||
self.started.elapsed().as_millis() as i64 + 1_000_000
|
||||
}
|
||||
/// `KeyboardHandler.tick`: the debug crash countdown.
|
||||
pub(super) fn tick_debug_keys(&mut self) -> Result<()> {
|
||||
let now = self.debug_millis();
|
||||
match self.debug_keys.tick(now) {
|
||||
None => {}
|
||||
Some(CrashNotice::Message) => {
|
||||
let args = [
|
||||
self.key_text("key.debug.modifier"),
|
||||
self.key_text("key.debug.crash"),
|
||||
];
|
||||
self.debug_feedback(Text::translate("debug.crash.message", &args));
|
||||
}
|
||||
Some(CrashNotice::Warning(seconds)) => self.debug_warning(Text::translate(
|
||||
"debug.crash.warning",
|
||||
&[Text::literal(seconds.to_string())],
|
||||
)),
|
||||
Some(CrashNotice::Crash) => {
|
||||
// Minecraft.emergencySaveAndCrash: the world is saved, then the
|
||||
// game stops with the report.
|
||||
eprintln!(
|
||||
"---- Minecraft Crash Report ----\n\nDescription: Manually triggered debug crash\n\njava.lang.Throwable: Manually triggered debug crash"
|
||||
);
|
||||
let _ = self.save_game();
|
||||
return Err("Manually triggered debug crash".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `KeyMapping.getTranslatedKeyMessage`.
|
||||
fn key_text(&self, mapping: &str) -> Text {
|
||||
keybinds::display_name(self.settings.keys.get(mapping))
|
||||
}
|
||||
/// `KeyboardHandler.decorateDebugComponent`: `[Debug]:` in bold, a space,
|
||||
/// the message, shown with `Minecraft.showDebugChat`.
|
||||
fn debug_decorated(&mut self, color: u32, message: Text) {
|
||||
let prefix = Text::translate("debug.prefix", &[])
|
||||
.with_color(color)
|
||||
.bold();
|
||||
self.system_message(
|
||||
Text::empty()
|
||||
.append(prefix)
|
||||
.append(Text::literal(" "))
|
||||
.append(message),
|
||||
);
|
||||
}
|
||||
fn debug_feedback(&mut self, message: Text) {
|
||||
self.debug_decorated(YELLOW, message);
|
||||
}
|
||||
fn debug_warning(&mut self, message: Text) {
|
||||
self.debug_decorated(RED, message);
|
||||
}
|
||||
fn debug_translated(&mut self, key: &str) {
|
||||
self.debug_feedback(Text::translate(key, &[]));
|
||||
}
|
||||
/// `LocalPlayer.isReducedDebugInfo`: the server's rule or the option.
|
||||
fn reduced_debug_info(&self) -> bool {
|
||||
self.settings.reduced_debug_info || self.ui.rules.bool("reduced_debug_info")
|
||||
}
|
||||
/// `Permissions.COMMANDS_GAMEMASTER` (`GameModeCommand.PERMISSION_CHECK`).
|
||||
fn gamemaster(&self) -> bool {
|
||||
self.ui.registries.permission >= 2
|
||||
}
|
||||
fn toggle_option(&mut self, key: &str) -> bool {
|
||||
let on = !self.settings.file.bool(key);
|
||||
self.settings
|
||||
.file
|
||||
.set(key, horizon_client::options_file::V::Bool(on));
|
||||
self.settings.save(&Self::options_path());
|
||||
on
|
||||
}
|
||||
/// One matching mapping of `handleDebugKeys`; whether it counts.
|
||||
fn run_debug_action(&mut self, action: DebugAction, shift: bool) -> bool {
|
||||
let player = self.player.is_some() && !self.reduced_debug_info();
|
||||
match action {
|
||||
DebugAction::ReloadChunks => {
|
||||
// LevelExtractor.allChanged: every section is rebuilt.
|
||||
let keys: Vec<(i32, i32)> = self.world.chunks.keys().copied().collect();
|
||||
for (x, z) in keys {
|
||||
for sy in 0..horizon_client::chunk::HEIGHT / 16 {
|
||||
self.world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
}
|
||||
self.debug_translated("debug.reload_chunks.message");
|
||||
true
|
||||
}
|
||||
DebugAction::ShowHitboxes if player => {
|
||||
self.show_hitboxes = !self.show_hitboxes;
|
||||
self.debug_translated(if self.show_hitboxes {
|
||||
"debug.show_hitboxes.on"
|
||||
} else {
|
||||
"debug.show_hitboxes.off"
|
||||
});
|
||||
true
|
||||
}
|
||||
DebugAction::ClearChat => {
|
||||
// ChatComponent.clearMessages(false): sent history stays.
|
||||
self.ui.chat.clear();
|
||||
true
|
||||
}
|
||||
DebugAction::ShowChunkBorders if player => {
|
||||
self.show_chunk_borders = !self.show_chunk_borders;
|
||||
self.debug_translated(if self.show_chunk_borders {
|
||||
"debug.chunk_boundaries.on"
|
||||
} else {
|
||||
"debug.chunk_boundaries.off"
|
||||
});
|
||||
true
|
||||
}
|
||||
DebugAction::AdvancedTooltips => {
|
||||
// The message comes before Options.save here.
|
||||
let on = !self.settings.file.bool("advancedItemTooltips");
|
||||
self.debug_translated(if on {
|
||||
"debug.advanced_tooltips.on"
|
||||
} else {
|
||||
"debug.advanced_tooltips.off"
|
||||
});
|
||||
self.toggle_option("advancedItemTooltips");
|
||||
true
|
||||
}
|
||||
DebugAction::CopyRecreateCommand => {
|
||||
if player {
|
||||
self.copy_recreate_command(self.gamemaster(), !shift);
|
||||
}
|
||||
true
|
||||
}
|
||||
DebugAction::Spectate => {
|
||||
if self.player.is_none() || !self.gamemaster() {
|
||||
self.debug_translated("debug.creative_spectator.error");
|
||||
} else if self.mode != GameMode::Spectator {
|
||||
self.change_game_mode(GameMode::Spectator);
|
||||
} else {
|
||||
let back = self.previous_mode.unwrap_or(GameMode::Creative);
|
||||
self.change_game_mode(back);
|
||||
}
|
||||
true
|
||||
}
|
||||
DebugAction::SwitchGameMode if self.open_screen() == OpenScreen::None => {
|
||||
// Minecraft.canSwitchGameMode and the permission; the
|
||||
// GameModeSwitcherScreen itself is not drawn yet.
|
||||
if self.player.is_none() || !self.gamemaster() {
|
||||
self.debug_translated("debug.gamemodes.error");
|
||||
}
|
||||
true
|
||||
}
|
||||
DebugAction::FocusPause => {
|
||||
let on = self.toggle_option("pauseOnLostFocus");
|
||||
self.debug_translated(if on {
|
||||
"debug.pause_focus.on"
|
||||
} else {
|
||||
"debug.pause_focus.off"
|
||||
});
|
||||
true
|
||||
}
|
||||
DebugAction::ReloadResourcePacks => {
|
||||
self.debug_translated("debug.reload_resourcepacks.message");
|
||||
self.reload_packs();
|
||||
true
|
||||
}
|
||||
DebugAction::CopyLocation if player => {
|
||||
self.debug_translated("debug.copy_location.message");
|
||||
if let Some(p) = &self.player {
|
||||
use horizon_client::java_text::format_fixed2 as f2;
|
||||
let command = format!(
|
||||
"/execute in {} run tp @s {} {} {} {} {}",
|
||||
self.dim.id(),
|
||||
f2(p.position.x),
|
||||
f2(p.position.y),
|
||||
f2(p.position.z),
|
||||
f2(f64::from(self.yaw)),
|
||||
f2(f64::from(self.pitch)),
|
||||
);
|
||||
self.set_clipboard(&command);
|
||||
}
|
||||
true
|
||||
}
|
||||
DebugAction::DumpVersion => {
|
||||
self.debug_translated("debug.version.header");
|
||||
for line in self.version_lines() {
|
||||
self.system_message(line);
|
||||
}
|
||||
true
|
||||
}
|
||||
DebugAction::ImprovedTransparency => {
|
||||
self.settings.improved_transparency = !self.settings.improved_transparency;
|
||||
self.settings.save(&Self::options_path());
|
||||
self.apply_settings();
|
||||
self.debug_translated(if self.settings.improved_transparency {
|
||||
"debug.improvedTransparency.on"
|
||||
} else {
|
||||
"debug.improvedTransparency.off"
|
||||
});
|
||||
true
|
||||
}
|
||||
// Screens and overlays Horizon does not draw yet (the debug
|
||||
// options screen, the profiler and its charts, the lightmap, the
|
||||
// texture dump): the keys are used up as in vanilla.
|
||||
DebugAction::DebugOptions
|
||||
| DebugAction::DumpDynamicTextures
|
||||
| DebugAction::Profiling
|
||||
| DebugAction::ProfilingChart
|
||||
| DebugAction::FpsCharts
|
||||
| DebugAction::NetworkCharts
|
||||
| DebugAction::LightmapTexture => true,
|
||||
DebugAction::ShowHitboxes
|
||||
| DebugAction::ShowChunkBorders
|
||||
| DebugAction::SwitchGameMode
|
||||
| DebugAction::CopyLocation => false,
|
||||
}
|
||||
}
|
||||
/// `ServerboundChangeGameModePacket` handled by the integrated server:
|
||||
/// `GameModeCommand.setGameMode` with its feedback.
|
||||
pub(super) fn change_game_mode(&mut self, mode: GameMode) {
|
||||
// On a server the packet decides; Horizon's client does not send it yet.
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
if self.set_game_mode(mode) {
|
||||
let name = Text::translate(&format!("gameMode.{}", mode.name()), &[]);
|
||||
self.feedback(Text::translate("commands.gamemode.success.self", &[name]));
|
||||
}
|
||||
}
|
||||
/// `KeyboardHandler.copyRecreateCommand`: the targeted block or mob as a
|
||||
/// `/setblock` or `/summon` command. In a single-player world the
|
||||
/// server's data (no Shift) is the game's own.
|
||||
fn copy_recreate_command(&mut self, add_nbt: bool, from_server: bool) {
|
||||
use horizon_client::java_text::format_fixed2 as f2;
|
||||
let side = if add_nbt && from_server {
|
||||
"server"
|
||||
} else {
|
||||
"client"
|
||||
};
|
||||
if self.entity_targeted()
|
||||
&& let Some((index, _)) = self.mob_hit()
|
||||
{
|
||||
let (id, pos, mob) = {
|
||||
let m = &self.mobs[index];
|
||||
(m.kind.id.clone(), m.position, m.id)
|
||||
};
|
||||
let mut command = format!("/summon {id} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
|
||||
if add_nbt && let Some(mut tag) = self.entity_nbt(super::commands::Target::Mob(mob)) {
|
||||
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
|
||||
for key in ["id", "UUID", "Pos"] {
|
||||
c.remove(&key.into());
|
||||
}
|
||||
}
|
||||
command.push(' ');
|
||||
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
|
||||
}
|
||||
self.set_clipboard(&command);
|
||||
self.debug_translated(&format!("debug.inspect.{side}.entity"));
|
||||
return;
|
||||
}
|
||||
let Some(hit) = self.pick() else {
|
||||
return;
|
||||
};
|
||||
let p = hit.pos;
|
||||
let mut description = self.state_string(self.world.block(p[0], p[1], p[2]));
|
||||
if add_nbt && let Some(e) = self.world.block_entities.get(&(p[0], p[1], p[2])) {
|
||||
// BlockEntity.saveWithoutMetadata: no id or position.
|
||||
let name = self.data_block_name(p);
|
||||
let mut tag = horizon_client::save::encode_block_entity((p[0], p[1], p[2]), e, &name);
|
||||
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
|
||||
for key in ["id", "x", "y", "z", "keepPacked"] {
|
||||
c.remove(&key.into());
|
||||
}
|
||||
}
|
||||
description.push_str(&horizon_client::snbt::to_snbt(&tag));
|
||||
}
|
||||
self.set_clipboard(&format!(
|
||||
"/setblock {} {} {} {description}",
|
||||
p[0], p[1], p[2]
|
||||
));
|
||||
self.debug_translated(&format!("debug.inspect.{side}.block"));
|
||||
}
|
||||
/// `KeyboardHandler.setClipboard`.
|
||||
fn set_clipboard(&mut self, text: &str) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.clipboard.is_none() {
|
||||
self.clipboard = arboard::Clipboard::new().ok();
|
||||
}
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
let _ = c.set_text(text.to_owned());
|
||||
}
|
||||
self.copied = Some(text.to_owned());
|
||||
}
|
||||
/// The debug renderers' gizmos for a frame (`DebugRenderer.emitGizmos`).
|
||||
pub(super) fn debug_gizmos(&self, alpha: f32) -> Gizmos {
|
||||
let mut g = Gizmos::default();
|
||||
if self.show_chunk_borders
|
||||
&& let Some(p) = &self.player
|
||||
{
|
||||
let block = [
|
||||
p.position.x.floor() as i32,
|
||||
p.position.y.floor() as i32,
|
||||
p.position.z.floor() as i32,
|
||||
];
|
||||
let min_y = horizon_client::chunk::MIN_Y;
|
||||
g.chunk_borders(block, min_y, min_y + horizon_client::chunk::HEIGHT - 1);
|
||||
}
|
||||
if !self.show_hitboxes {
|
||||
return g;
|
||||
}
|
||||
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
||||
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
|
||||
for m in &self.mobs {
|
||||
if m.removed || m.has_effect("minecraft:invisibility") {
|
||||
continue;
|
||||
}
|
||||
let pos = [
|
||||
lerp(m.previous.x, m.position.x),
|
||||
lerp(m.previous.y, m.position.y),
|
||||
lerp(m.previous.z, m.position.z),
|
||||
];
|
||||
let view = (
|
||||
lerpf(m.previous_head_pitch, m.head_pitch),
|
||||
lerpf(m.previous_head_yaw, m.head_yaw),
|
||||
);
|
||||
g.hitbox(
|
||||
pos,
|
||||
(m.width(), m.height()),
|
||||
m.eye_height() as f32,
|
||||
true,
|
||||
view,
|
||||
);
|
||||
}
|
||||
for item in &self.dropped {
|
||||
let pos = [
|
||||
lerp(item.previous.x, item.position.x),
|
||||
lerp(item.previous.y, item.position.y),
|
||||
lerp(item.previous.z, item.position.z),
|
||||
];
|
||||
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
|
||||
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
|
||||
}
|
||||
// The dragon's parts.
|
||||
for (id, b) in self.special_targets() {
|
||||
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
|
||||
g.cuboid(
|
||||
[b.min(Axis::X), b.min(Axis::Y), b.min(Axis::Z)],
|
||||
[b.max(Axis::X), b.max(Axis::Y), b.max(Axis::Z)],
|
||||
horizon_client::gizmos::DRAGON_PART,
|
||||
horizon_client::gizmos::STROKE_WIDTH,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The player's own, unless the camera is in its head.
|
||||
if !self.camera_type.first_person()
|
||||
&& let Some(p) = &self.player
|
||||
{
|
||||
let d = p.dimensions;
|
||||
let pos = [
|
||||
lerp(self.previous_position.x, p.position.x),
|
||||
lerp(self.previous_position.y, p.position.y),
|
||||
lerp(self.previous_position.z, p.position.z),
|
||||
];
|
||||
g.hitbox(
|
||||
pos,
|
||||
(d.width, d.height),
|
||||
d.eye_height,
|
||||
true,
|
||||
(self.pitch, self.yaw),
|
||||
);
|
||||
}
|
||||
g
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,8 @@ impl Game<'_> {
|
||||
self.level_events.clear();
|
||||
self.orbs.clear();
|
||||
self.falling.clear();
|
||||
// Hopper cooldowns of an earlier case.
|
||||
self.hoppers.clear();
|
||||
self.ui.rules = Default::default();
|
||||
// The oracle level plays on normal difficulty.
|
||||
self.ui.difficulty = 2;
|
||||
@@ -260,8 +262,15 @@ impl Game<'_> {
|
||||
pos[2].as_i64().unwrap_or(0) as i32,
|
||||
);
|
||||
let state = self.world.block(origin.x, origin.y, origin.z);
|
||||
let mut used = Vec::new();
|
||||
if let Some(op) = case.get("remove") {
|
||||
self.fixture_remove(origin, op)?;
|
||||
} else if let Some(op) = case.get("use") {
|
||||
used = self.fixture_use(origin, op)?;
|
||||
} else if case.get("hopper").is_some() {
|
||||
// HopperBlockEntity.pushItemsTick, once.
|
||||
self.hopper_try_move_items((origin.x, origin.y, origin.z));
|
||||
self.process_level_events()?;
|
||||
} else {
|
||||
self.dispense_from(origin, state)?;
|
||||
// CrafterBlockEntity.serverTick, the crafting animation running down.
|
||||
@@ -335,10 +344,64 @@ impl Game<'_> {
|
||||
for (sound, at, _, _) in self.audio.capture.take().unwrap_or_default() {
|
||||
out.push(format!("S {} {sound}", floor_pos(at)));
|
||||
}
|
||||
out.extend(used);
|
||||
out.push(format!("rng {}", self.world.random.next_long()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A player holding the case's item right-clicks the block at `pos`
|
||||
/// (`use_item_as_player`'s block steps): the statistics, the result
|
||||
/// and the held stack, as `VanillaDispenserOracle.use` prints them.
|
||||
fn fixture_use(&mut self, pos: BlockPos, op: &Value) -> Result<Vec<String>> {
|
||||
let stack = parse_stack(op["item"].as_str().unwrap_or("-"))?;
|
||||
self.creative = op["creative"].as_bool().unwrap_or(false);
|
||||
self.inventory[self.selected] = stack.clone();
|
||||
let used_before = stack
|
||||
.as_ref()
|
||||
.map_or(0, |s| self.stats.get("used", &s.item));
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
let mut consumed = self.use_composter(at, stack.as_ref());
|
||||
if !consumed {
|
||||
let player = horizon_world::level::PlayerContext {
|
||||
yaw: 0.0,
|
||||
pitch: 90.0,
|
||||
may_build: true,
|
||||
can_eat: self.creative,
|
||||
game_master: false,
|
||||
};
|
||||
let hit = horizon_core::geometry::Vec3::new(
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 1.0,
|
||||
f64::from(pos.z) + 0.5,
|
||||
);
|
||||
let result = self.with_level(|b, level, ticks| {
|
||||
b.use_without_item(
|
||||
level,
|
||||
ticks,
|
||||
pos,
|
||||
horizon_core::geometry::Direction::Up,
|
||||
hit,
|
||||
player,
|
||||
)
|
||||
});
|
||||
consumed = result.consumes_action();
|
||||
}
|
||||
self.process_level_events()?;
|
||||
let mut out = Vec::new();
|
||||
if let Some(s) = &stack {
|
||||
let used = self.stats.get("used", &s.item) - used_before;
|
||||
if used != 0 {
|
||||
out.push(format!("stat {} minecraft:used {used}", s.item));
|
||||
}
|
||||
}
|
||||
out.push(format!(
|
||||
"use {} held {}",
|
||||
if consumed { "consumed" } else { "pass" },
|
||||
canon_stack(self.inventory[self.selected].as_ref())
|
||||
));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A stack in a block entity's slot: a container's, a jukebox's record, a
|
||||
/// lectern's book, or the items of a block entity Horizon keeps as saved.
|
||||
fn fixture_fill(&mut self, p: (i32, i32, i32), slot: usize, stack: Option<ItemStack>) {
|
||||
|
||||
@@ -722,6 +722,7 @@ impl Game<'_> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut amount = -1.0;
|
||||
// Level.getCollisions(null, aabb): CollisionContext.empty().
|
||||
|
||||
@@ -2109,6 +2109,7 @@ impl Game<'_> {
|
||||
// A triangle as a quad with its last corner repeated.
|
||||
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(p).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
@@ -2173,6 +2174,7 @@ impl Game<'_> {
|
||||
),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(p).to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
@@ -2217,6 +2219,7 @@ impl Game<'_> {
|
||||
(p - right * h + up * h, [s.u0, s.v0]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: c.as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
|
||||
@@ -169,6 +169,7 @@ impl Game<'_> {
|
||||
for (x, z, uv) in corners {
|
||||
let p = pose.transform_point3(glam::Vec3::new(x, -100.0, z));
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
@@ -203,6 +204,7 @@ impl Game<'_> {
|
||||
for (x, z, uv) in corners {
|
||||
let p = pose.transform_point3(glam::Vec3::new(x * 60.0, 100.0, z * 60.0));
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
@@ -483,6 +485,7 @@ impl Game<'_> {
|
||||
player.fall_distance = 0.0;
|
||||
self.previous_position = player.position;
|
||||
self.player = Some(player);
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
let pitch = self.world.random.next_float() * 0.4 + 0.8;
|
||||
self.audio
|
||||
@@ -990,6 +993,7 @@ impl Game<'_> {
|
||||
];
|
||||
for (pos, uv) in quad {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: pos.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -1127,6 +1131,7 @@ impl Game<'_> {
|
||||
];
|
||||
for (pos, uv) in corners {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: pos.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
|
||||
@@ -128,6 +128,7 @@ impl Game<'_> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let h = 0.125;
|
||||
let bounds = Aabb::new(
|
||||
@@ -272,6 +273,7 @@ impl Game<'_> {
|
||||
.zip([[s.u0, s.v1], [s.u1, s.v1], [s.u1, s.v0], [s.u0, s.v0]])
|
||||
{
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
|
||||
@@ -444,6 +444,7 @@ impl Game<'_> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(bounds(h.position), h.velocity, 0.0, h.on_ground) {
|
||||
let horizontal =
|
||||
@@ -485,6 +486,7 @@ impl Game<'_> {
|
||||
let mut quad = |c: [glam::DVec3; 4], uv: [[f32; 2]; 4], color: u32| {
|
||||
for (v, uv) in c.iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (*v - camera).as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
|
||||
@@ -104,6 +104,7 @@ impl Game<'_> {
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(bounds).unwrap_or(false) {
|
||||
return false;
|
||||
@@ -333,6 +334,7 @@ impl Game<'_> {
|
||||
for (c, uv) in corners.iter().zip(uv) {
|
||||
let p = m.transform_point3(glam::DVec3::from_array(*c));
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
|
||||
@@ -31,6 +31,10 @@ impl Game<'_> {
|
||||
/// `ServerPlayer.setGameMode` and `GameType.updatePlayerAbilities`.
|
||||
pub(super) fn set_mode(&mut self, mode: GameMode) {
|
||||
let was_spectator = self.spectator();
|
||||
// MultiPlayerGameMode.setLocalMode: the mode it leaves.
|
||||
if mode != self.mode {
|
||||
self.previous_mode = Some(self.mode);
|
||||
}
|
||||
self.mode = mode;
|
||||
self.creative = mode == GameMode::Creative;
|
||||
// GameType.updatePlayerAbilities applies at once.
|
||||
|
||||
@@ -119,7 +119,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Whether an attack would hit an entity rather than the block behind
|
||||
/// it: the crosshair's `EntityHitResult`.
|
||||
fn entity_targeted(&self) -> bool {
|
||||
pub(super) fn entity_targeted(&self) -> bool {
|
||||
let before = |d: Option<f64>| d.is_some_and(|d| self.before_block(d));
|
||||
before(self.painting_hit().map(|h| h.1))
|
||||
|| before(self.frame_hit().map(|h| h.1))
|
||||
@@ -170,6 +170,10 @@ impl Game<'_> {
|
||||
/// strength and item swap tickers (`Player.tick`), then
|
||||
/// `FirstPersonHandsAndItems.tick`.
|
||||
pub(super) fn tick_hand(&mut self) {
|
||||
// LocalPlayer.applyInput (the camera is the player's own).
|
||||
if self.player.is_some() {
|
||||
self.sway.tick(self.pitch, self.yaw);
|
||||
}
|
||||
self.swing.tick();
|
||||
let main = self.inventory[self.selected].clone();
|
||||
self.hand_strength.tick(main.as_ref().map(|s| HeldStack {
|
||||
|
||||
@@ -15,7 +15,6 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// The game's answers to what the resolver cannot read from the stack.
|
||||
pub(super) struct Env<'a, 'g> {
|
||||
@@ -59,8 +58,7 @@ impl ItemEnv for Env<'_, '_> {
|
||||
total as f32
|
||||
}
|
||||
fn has_shift_down(&self) -> bool {
|
||||
self.game.keys.contains(&KeyCode::ShiftLeft)
|
||||
|| self.game.keys.contains(&KeyCode::ShiftRight)
|
||||
self.game.has_shift_down()
|
||||
}
|
||||
fn local_time(&self, pattern: &str, _locale: &str, _time_zone: Option<&str>) -> String {
|
||||
local_time(pattern)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//! The sprites of item particles: `BreakingItemParticle.ItemParticleProvider.getSprite`
|
||||
//! resolves the stack's model for the ground and picks a layer's particle sprite.
|
||||
use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ItemParticleOption(ITEM, stack)`: the layer particle sprites of the
|
||||
/// stack's model resolved for the ground, with seed 0.
|
||||
pub(super) fn item_particle(&self, stack: &ItemStack) -> ParticleOptions {
|
||||
let state = self.resolve_item(stack, DisplayContext::Ground, None, 0);
|
||||
ParticleOptions::item(state.layers.iter().map(|l| l.particle.clone()).collect())
|
||||
}
|
||||
/// The slime ball, snowball and cobweb models behind `item_slime`,
|
||||
/// `item_snowball` and `item_cobweb`, resolved on first use.
|
||||
pub(super) fn ensure_item_particle_sprites(&mut self) {
|
||||
if !self.particles.item_sprites.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (ty, item) in [
|
||||
(ParticleType::ItemSlime, "minecraft:slime_ball"),
|
||||
(ParticleType::ItemSnowball, "minecraft:snowball"),
|
||||
(ParticleType::ItemCobweb, "minecraft:cobweb"),
|
||||
] {
|
||||
let sprites = self
|
||||
.item_particle(&ItemStack::new(item, 1))
|
||||
.item
|
||||
.unwrap_or_default();
|
||||
self.particles.item_sprites.insert(ty, sprites);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ pub(super) struct GuestPlay {
|
||||
last_progress: i32,
|
||||
/// Hand movements the client announced and the host animated.
|
||||
swung: bool,
|
||||
/// The trading screen the guest has open.
|
||||
pub(super) merchant: Option<super::lan_trading::GuestMerchant>,
|
||||
}
|
||||
|
||||
struct ItemRules<'a>(&'a horizon_client::items::Items);
|
||||
@@ -135,6 +137,7 @@ impl Game<'_> {
|
||||
self.left = play.digging.is_some();
|
||||
self.right = play.using.is_some() || self.using.is_some();
|
||||
let riding = self.riding.take();
|
||||
let host_station = self.station.take();
|
||||
self.lan_acting = Some(conn);
|
||||
|
||||
let result = f(self);
|
||||
@@ -142,6 +145,7 @@ impl Game<'_> {
|
||||
|
||||
// What the action opened: handed to the guest instead of the host.
|
||||
let opened = self.container.take();
|
||||
let opened_station = std::mem::replace(&mut self.station, host_station);
|
||||
self.riding = riding;
|
||||
self.left = left;
|
||||
self.right = right;
|
||||
@@ -199,6 +203,9 @@ impl Game<'_> {
|
||||
if let Some(pos) = opened {
|
||||
self.lan_open_container(host, conn, pos);
|
||||
}
|
||||
if let Some(station) = opened_station {
|
||||
self.lan_station_opened(host, conn, station);
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
@@ -384,6 +391,14 @@ impl Game<'_> {
|
||||
let item = self.item_from_network(host, &p.item);
|
||||
host.hub.guests[gi].set_menu_slot(p.slot as usize, item);
|
||||
}
|
||||
S::ContainerClick(p) if self.lan_merchant_click(host, conn, &p) => {}
|
||||
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
|
||||
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
let rules = ItemRules(&self.res.items);
|
||||
let drops = host.hub.container_click(conn, p, &mut self.world, &rules);
|
||||
@@ -445,7 +460,7 @@ impl Game<'_> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn drop_guest_items(&mut self, host: &LanHost, gi: usize, stacks: Vec<ItemStack>) {
|
||||
pub(super) fn drop_guest_items(&mut self, host: &LanHost, gi: usize, stacks: Vec<ItemStack>) {
|
||||
let g = &host.hub.guests[gi];
|
||||
let eye = [
|
||||
g.position[0],
|
||||
@@ -615,6 +630,7 @@ impl Game<'_> {
|
||||
|
||||
/// Per tick, for every guest: dig, use items, pick up drops, die.
|
||||
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
|
||||
self.lan_merchant_tick(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Trading for LAN guests: the villager screen a guest opens runs the same
|
||||
//! merchant menu as the host's (`horizon_client::menu`, `Kind::Merchant`),
|
||||
//! with vanilla's packets: OpenScreen (`minecraft:merchant`), MerchantOffers,
|
||||
//! the select trade packet, container clicks and the content sync after each.
|
||||
use super::{Game, lan::LanHost, stations::Kind as StationKind, trading::MerchantState};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
merchant,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A guest's open trading screen.
|
||||
pub(super) struct GuestMerchant {
|
||||
pub(super) window: i32,
|
||||
state: i32,
|
||||
/// The payment slots and the result.
|
||||
slots: [Option<ItemStack>; 3],
|
||||
pub(super) merchant: Box<MerchantState>,
|
||||
/// The offers the client was last sent.
|
||||
sent: Vec<merchant::MerchantOffer>,
|
||||
sent_level: i32,
|
||||
}
|
||||
|
||||
/// A guest's UUID as gossip targets it.
|
||||
fn uuid_words(uuid: u128) -> [i32; 4] {
|
||||
[
|
||||
(uuid >> 96) as i32,
|
||||
(uuid >> 64) as i32,
|
||||
(uuid >> 32) as i32,
|
||||
uuid as i32,
|
||||
]
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A trading screen the guest's interaction opened (it opened as a
|
||||
/// station while the guest acted): handed to the guest.
|
||||
pub(super) fn lan_open_merchant(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
mut state: MerchantState,
|
||||
) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let uuid = uuid_words(host.hub.guests[gi].uuid);
|
||||
if let Some(id) = state.mob
|
||||
&& state.session.villager
|
||||
{
|
||||
// startTrading with the guest's reputation (their effects are
|
||||
// not modelled on the host: no Hero of the Village).
|
||||
state.session.reputation = self.villager_reputation_of(id, uuid);
|
||||
state.session.hero = None;
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
let Some(window) = host.hub.open_merchant_window(conn, title) else {
|
||||
self.merchant_closed(&mut state);
|
||||
return;
|
||||
};
|
||||
let mut gm = GuestMerchant {
|
||||
window,
|
||||
state: 0,
|
||||
slots: [None, None, None],
|
||||
merchant: Box::new(state),
|
||||
sent: Vec::new(),
|
||||
sent_level: 0,
|
||||
};
|
||||
self.lan_send_offers(host, conn, &mut gm, true);
|
||||
self.lan_send_merchant_content(host, conn, &mut gm);
|
||||
host.play.entry(conn).or_default().merchant = Some(gm);
|
||||
}
|
||||
/// `Entity.getDisplayName` of the merchant, the screen's title: its name
|
||||
/// (custom, else the profession's) with the show_entity hover event and
|
||||
/// its UUID as insertion.
|
||||
fn merchant_display_name(&mut self, state: &MerchantState) -> horizon_world::nbt::Tag {
|
||||
use horizon_world::nbt::Tag;
|
||||
let s = &state.session;
|
||||
let (kind, key) = if s.villager {
|
||||
let p = s.profession.trim_start_matches("minecraft:");
|
||||
(
|
||||
"minecraft:villager",
|
||||
format!("entity.minecraft.villager.{p}"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"minecraft:wandering_trader",
|
||||
"entity.minecraft.wandering_trader".to_owned(),
|
||||
)
|
||||
};
|
||||
let name = match &state.name {
|
||||
Some(n) => horizon_protocol::text::literal(n),
|
||||
None => Tag::compound([("translate", Tag::String(key.as_str().into()))]),
|
||||
};
|
||||
let uuid = state.mob.map_or([0; 4], |id| self.mob_uuid(id));
|
||||
let w = uuid.map(|v| v as u32);
|
||||
let text = format!(
|
||||
"{:08x}-{:04x}-{:04x}-{:04x}-{:04x}{:08x}",
|
||||
w[0],
|
||||
w[1] >> 16,
|
||||
w[1] & 0xffff,
|
||||
w[2] >> 16,
|
||||
w[2] & 0xffff,
|
||||
w[3]
|
||||
);
|
||||
let mut out = match name.clone() {
|
||||
Tag::Compound(c) => c,
|
||||
other => {
|
||||
let mut c = horizon_world::nbt::Compound::new();
|
||||
c.insert("text".into(), other);
|
||||
c
|
||||
}
|
||||
};
|
||||
out.insert(
|
||||
"hover_event".into(),
|
||||
Tag::compound([
|
||||
("action", Tag::String("show_entity".into())),
|
||||
("id", Tag::String(kind.into())),
|
||||
("uuid", Tag::IntArray(uuid.to_vec())),
|
||||
("name", name),
|
||||
]),
|
||||
);
|
||||
out.insert("insertion".into(), Tag::String(text.as_str().into()));
|
||||
Tag::Compound(out)
|
||||
}
|
||||
/// The screen title: the custom name, else the profession (the client
|
||||
/// adds the level itself).
|
||||
#[allow(dead_code)]
|
||||
fn merchant_title(&self, state: &MerchantState) -> String {
|
||||
if let Some(name) = &state.name {
|
||||
return name.clone();
|
||||
}
|
||||
let e = &self.res.stations.enchantments;
|
||||
let profession = state.session.profession.trim_start_matches("minecraft:");
|
||||
if state.session.villager {
|
||||
e.translate(&format!("entity.minecraft.villager.{profession}"))
|
||||
.unwrap_or(profession)
|
||||
.to_owned()
|
||||
} else {
|
||||
e.translate("entity.minecraft.wandering_trader")
|
||||
.unwrap_or("Wandering Trader")
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.sendMerchantOffers`, when the offers changed.
|
||||
fn lan_send_offers(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
gm: &mut GuestMerchant,
|
||||
force: bool,
|
||||
) {
|
||||
let s = &gm.merchant.session;
|
||||
if !force && s.offers == gm.sent && s.level == gm.sent_level {
|
||||
return;
|
||||
}
|
||||
let offers: Vec<play::NetOffer> = s
|
||||
.offers
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let cost =
|
||||
|st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| play::NetCost {
|
||||
item: hub.item_id(&st.item),
|
||||
count: st.count as i32,
|
||||
};
|
||||
Some(play::NetOffer {
|
||||
cost_a: cost(&o.base_cost_a.stack, &mut host.hub),
|
||||
result: host.hub.stack(&Some(o.result.clone()))?,
|
||||
cost_b: o.cost_b.as_ref().map(|b| cost(&b.stack, &mut host.hub)),
|
||||
out_of_stock: o.out_of_stock(),
|
||||
uses: o.uses,
|
||||
max_uses: o.max_uses,
|
||||
xp: o.xp,
|
||||
special_price: o.special_price_diff,
|
||||
price_multiplier: o.price_multiplier,
|
||||
demand: o.demand,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::MerchantOffers(play::MerchantOffers {
|
||||
container: gm.window,
|
||||
offers,
|
||||
level: s.level,
|
||||
xp: s.xp,
|
||||
show_progress: s.villager,
|
||||
can_restock: s.villager,
|
||||
}),
|
||||
);
|
||||
gm.sent = s.offers.clone();
|
||||
gm.sent_level = s.level;
|
||||
}
|
||||
fn lan_send_merchant_content(&mut self, host: &mut LanHost, conn: u32, gm: &mut GuestMerchant) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let mut items: Vec<Option<ItemStack>> = gm.slots.to_vec();
|
||||
let g = &host.hub.guests[gi];
|
||||
items.extend((9..36).chain(0..9).map(|i| g.inventory[i].clone()));
|
||||
let carried = g.carried.clone();
|
||||
let net: Vec<_> = items.iter().map(|s| host.hub.stack(s)).collect();
|
||||
let carried = host.hub.stack(&carried);
|
||||
gm.state = (gm.state + 1) & 0x7FFF;
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerSetContent(play::ContainerSetContent {
|
||||
container: gm.window,
|
||||
state: gm.state,
|
||||
items: net,
|
||||
carried,
|
||||
}),
|
||||
);
|
||||
}
|
||||
/// Runs `op` on the guest's merchant menu (their inventory copied in and
|
||||
/// back), then syncs the client. False when the guest has no such window.
|
||||
fn lan_merchant_op(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: Option<i32>,
|
||||
op: impl FnOnce(&mut Menu, &Env),
|
||||
) -> bool {
|
||||
let Some(mut gm) = host.play.get_mut(&conn).and_then(|p| p.merchant.take()) else {
|
||||
return false;
|
||||
};
|
||||
if window.is_some_and(|w| w != gm.window) {
|
||||
host.play.entry(conn).or_default().merchant = Some(gm);
|
||||
return false;
|
||||
}
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return false;
|
||||
};
|
||||
let mut session = gm.merchant.session.clone();
|
||||
self.merchant_sync_in(&mut session, gm.merchant.mob);
|
||||
let next_map = self.peek_map_id();
|
||||
let tag = |t: &str| self.structure_tag(t);
|
||||
let maps = super::trading::GameMaps {
|
||||
terrain: self.end.locator(),
|
||||
structure_tag: &tag,
|
||||
made: Default::default(),
|
||||
next_id: std::cell::Cell::new(next_map),
|
||||
};
|
||||
let trade_env = merchant::TradeEnv {
|
||||
trades: &self.res.stations.trades,
|
||||
enchantments: &self.res.stations.enchantments,
|
||||
maps: Some(&maps),
|
||||
};
|
||||
let env = Env {
|
||||
items: &self.res.items,
|
||||
recipes: &self.res.recipes,
|
||||
brewing: &self.res.stations.brewing,
|
||||
banners: &self.res.stations.banners,
|
||||
trade: Some(&trade_env),
|
||||
};
|
||||
let mut menu = Menu::new(Kind::Merchant);
|
||||
{
|
||||
let g = &host.hub.guests[gi];
|
||||
for i in 0..36 {
|
||||
menu.set_player_item(i, g.inventory[i].clone());
|
||||
}
|
||||
menu.set_player_item(40, g.offhand.clone());
|
||||
menu.selected = g.selected;
|
||||
menu.creative = g.creative();
|
||||
menu.carried = g.carried.clone();
|
||||
}
|
||||
for (i, s) in gm.slots.iter().enumerate() {
|
||||
menu.set_slot(&env, i, s.clone());
|
||||
}
|
||||
menu.merchant = Some(session);
|
||||
op(&mut menu, &env);
|
||||
let made = maps.made.take();
|
||||
let session = menu.merchant.take().expect("attached");
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
gm.slots = [
|
||||
menu.slot(0).cloned(),
|
||||
menu.slot(1).cloned(),
|
||||
menu.slot(2).cloned(),
|
||||
];
|
||||
{
|
||||
let g = &mut host.hub.guests[gi];
|
||||
for i in 0..36 {
|
||||
g.inventory[i] = menu.player_item(i).cloned();
|
||||
}
|
||||
g.offhand = menu.player_item(40).cloned();
|
||||
g.carried = menu.carried.take();
|
||||
}
|
||||
self.register_exploration_maps(made);
|
||||
gm.merchant.session = self.merchant_apply(session, gm.merchant.mob);
|
||||
self.drop_guest_items(host, gi, dropped);
|
||||
self.lan_send_offers(host, conn, &mut gm, false);
|
||||
self.lan_send_merchant_content(host, conn, &mut gm);
|
||||
host.play.entry(conn).or_default().merchant = Some(gm);
|
||||
true
|
||||
}
|
||||
/// A container click on the guest's trading window.
|
||||
pub(super) fn lan_merchant_click(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &play::ContainerClick,
|
||||
) -> bool {
|
||||
let input = match p.input {
|
||||
0 => Input::Pickup,
|
||||
1 => Input::QuickMove,
|
||||
2 => Input::Swap,
|
||||
3 => Input::Clone,
|
||||
4 => Input::Throw,
|
||||
5 => Input::QuickCraft,
|
||||
_ => Input::PickupAll,
|
||||
};
|
||||
let (slot, button) = (i32::from(p.slot), i32::from(p.button));
|
||||
self.lan_merchant_op(host, conn, Some(p.container), |menu, env| {
|
||||
let _ = menu.click(env, slot, button, input);
|
||||
})
|
||||
}
|
||||
/// ServerboundSelectTradePacket: the selection hint, then tryMoveItems.
|
||||
pub(super) fn lan_select_trade(&mut self, host: &mut LanHost, conn: u32, index: i32) {
|
||||
let Ok(index) = usize::try_from(index) else {
|
||||
return;
|
||||
};
|
||||
self.lan_merchant_op(host, conn, None, |menu, env| {
|
||||
menu.merchant_select(env, index)
|
||||
});
|
||||
}
|
||||
/// The guest closed the trading window (or it is closed for them):
|
||||
/// MerchantMenu.removed, then the villager gets its state back.
|
||||
pub(super) fn lan_merchant_closed(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: Option<i32>,
|
||||
) -> bool {
|
||||
if !self.lan_merchant_op(host, conn, window, |menu, env| menu.merchant_close(env)) {
|
||||
return false;
|
||||
}
|
||||
let Some(mut gm) = host.play.get_mut(&conn).and_then(|p| p.merchant.take()) else {
|
||||
return false;
|
||||
};
|
||||
let uuid = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| uuid_words(g.uuid));
|
||||
if std::mem::take(&mut gm.merchant.session.last_traded)
|
||||
&& let (Some(id), Some(uuid)) = (gm.merchant.mob, uuid)
|
||||
{
|
||||
self.villager_traded_by(id, uuid);
|
||||
}
|
||||
self.merchant_closed(&mut gm.merchant);
|
||||
true
|
||||
}
|
||||
/// `Villager.customServerAiStep` for the guests' trading villagers: the
|
||||
/// TRADE reputation event, then the prices follow.
|
||||
pub(super) fn lan_merchant_tick(&mut self, host: &mut LanHost) {
|
||||
let conns: Vec<u32> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter(|(_, p)| {
|
||||
p.merchant
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.merchant.session.villager && m.merchant.session.last_traded)
|
||||
})
|
||||
.map(|(c, _)| *c)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
let Some(uuid) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| uuid_words(g.uuid))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(gm) = host.play.get_mut(&conn).and_then(|p| p.merchant.as_mut()) else {
|
||||
continue;
|
||||
};
|
||||
gm.merchant.session.last_traded = false;
|
||||
let Some(id) = gm.merchant.mob else { continue };
|
||||
self.villager_traded_by(id, uuid);
|
||||
let reputation = self.villager_reputation_of(id, uuid);
|
||||
self.lan_merchant_op(host, conn, None, |menu, env| {
|
||||
if let Some(s) = menu.merchant.as_mut() {
|
||||
s.reputation = reputation;
|
||||
s.update_special_prices();
|
||||
}
|
||||
menu.merchant_update(env);
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Takes a station a guest's action opened: a trading screen goes to the
|
||||
/// guest; other stations are not shown to guests yet.
|
||||
pub(super) fn lan_station_opened(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
station: super::stations::Station,
|
||||
) {
|
||||
let mut station = station;
|
||||
if station.kind == StationKind::Merchant
|
||||
&& let Some(m) = station.merchant.take()
|
||||
{
|
||||
self.lan_open_merchant(host, conn, *m);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,6 +373,7 @@ impl Game<'_> {
|
||||
for quad in [[a0, b0, b1, a1], [a1, b1, b0, a0]] {
|
||||
for (v, uv) in quad.iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (*v - camera).as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
|
||||
@@ -385,6 +385,7 @@ impl Game<'_> {
|
||||
];
|
||||
for c in corners {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (o + glam::Vec3::from_array(c)).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
|
||||
@@ -41,6 +41,9 @@ enum Kind {
|
||||
Crafter,
|
||||
/// `BrewingStandBlockEntity` (a `WorldlyContainer`).
|
||||
Brewing,
|
||||
/// `ComposterBlock.getContainer` at this level (a `WorldlyContainerHolder`
|
||||
/// without a block entity): input below 7, empty at 7, bone meal at 8.
|
||||
Composter(i32),
|
||||
}
|
||||
|
||||
/// A container: one block entity, or a double chest (`CompoundContainer`).
|
||||
@@ -71,6 +74,9 @@ impl View {
|
||||
(Kind::Brewing, Some(Direction::Up)) => vec![3],
|
||||
(Kind::Brewing, Some(Direction::Down)) => vec![0, 1, 2, 3],
|
||||
(Kind::Brewing, Some(_)) => vec![0, 1, 2, 4],
|
||||
(Kind::Composter(8), Some(Direction::Down)) => vec![0],
|
||||
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => vec![0],
|
||||
(Kind::Composter(_), _) => vec![],
|
||||
_ => (0..self.len()).collect(),
|
||||
}
|
||||
}
|
||||
@@ -119,6 +125,8 @@ struct Containers<'a> {
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
|
||||
brewing: Option<&'a horizon_client::brewing::Brewing>,
|
||||
/// The stack of a full composter's `OutputContainer`.
|
||||
bone_meal: ItemStack,
|
||||
}
|
||||
impl Containers<'_> {
|
||||
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
|
||||
@@ -141,9 +149,16 @@ impl Containers<'_> {
|
||||
/// first). `blocked_chests`: `ChestBlock.getContainer(.., false)` returns
|
||||
/// nothing for a chest under a redstone conductor.
|
||||
fn view(&self, pos: Pos, blocked_chests: bool) -> Option<View> {
|
||||
let entity = self.world.block_entities.get(&pos)?;
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let name = self.name(state);
|
||||
if name == "minecraft:composter" {
|
||||
let level = self.prop(state, "level").and_then(|l| l.parse().ok())?;
|
||||
return Some(View {
|
||||
parts: vec![(pos, usize::from(level != 7))],
|
||||
kind: Kind::Composter(level),
|
||||
});
|
||||
}
|
||||
let entity = self.world.block_entities.get(&pos)?;
|
||||
let kind = match (entity, name) {
|
||||
(BlockEntity::Furnace(_), _) => Kind::Furnace,
|
||||
(BlockEntity::Crafter(_), _) => Kind::Crafter,
|
||||
@@ -202,6 +217,10 @@ impl Containers<'_> {
|
||||
})
|
||||
}
|
||||
fn get(&self, view: &View, slot: usize) -> Option<&ItemStack> {
|
||||
if let Kind::Composter(level) = view.kind {
|
||||
// OutputContainer holds one bone meal; InputContainer composts at once.
|
||||
return (level == 8 && slot == 0).then_some(&self.bone_meal);
|
||||
}
|
||||
let (pos, i) = view.locate(slot)?;
|
||||
self.world
|
||||
.block_entities
|
||||
@@ -264,6 +283,10 @@ impl Containers<'_> {
|
||||
}
|
||||
/// `canTakeItemThroughFace` (furnaces and brewing stands restrict it).
|
||||
fn can_take(&self, view: &View, slot: usize, stack: &ItemStack, face: Direction) -> bool {
|
||||
if let Kind::Composter(level) = view.kind {
|
||||
// OutputContainer.canTakeItemThroughFace.
|
||||
return level == 8 && face == Direction::Down && &*stack.item == "minecraft:bone_meal";
|
||||
}
|
||||
if view.kind == Kind::Brewing {
|
||||
return slot != 3 || &*stack.item == "minecraft:glass_bottle";
|
||||
}
|
||||
@@ -300,6 +323,15 @@ impl Containers<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn bone_meal() -> ItemStack {
|
||||
ItemStack {
|
||||
item: "minecraft:bone_meal".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Comparator input from a container block entity (0 when there is none).
|
||||
pub(super) fn container_signal(
|
||||
world: &World,
|
||||
@@ -314,8 +346,10 @@ pub(super) fn container_signal(
|
||||
items,
|
||||
registry,
|
||||
brewing: None,
|
||||
bone_meal: bone_meal(),
|
||||
};
|
||||
c.view((pos.x, pos.y, pos.z), true)
|
||||
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
|
||||
.map_or(0, |v| c.signal(&v))
|
||||
}
|
||||
|
||||
@@ -327,6 +361,7 @@ impl Game<'_> {
|
||||
items: &self.res.items,
|
||||
registry: self.res.assets.registry,
|
||||
brewing: Some(&self.res.stations.brewing),
|
||||
bone_meal: bone_meal(),
|
||||
}
|
||||
}
|
||||
fn slot_mut_at(&mut self, view: &View, slot: usize) -> Option<&mut Option<ItemStack>> {
|
||||
@@ -339,6 +374,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// `BlockEntity.setChanged` for every block entity of a container.
|
||||
fn view_changed(&mut self, view: &View) {
|
||||
// A composter's containers change its block state instead.
|
||||
if let Kind::Composter(_) = view.kind {
|
||||
return;
|
||||
}
|
||||
for &(pos, _) in &view.parts {
|
||||
self.container_changed(pos);
|
||||
}
|
||||
@@ -415,6 +454,10 @@ impl Game<'_> {
|
||||
stack: ItemStack,
|
||||
face: Option<Direction>,
|
||||
) -> Option<ItemStack> {
|
||||
if let Kind::Composter(_) = view.kind {
|
||||
let (x, y, z) = view.parts[0].0;
|
||||
return self.composter_container_insert([x, y, z], stack, face == Some(Direction::Up));
|
||||
}
|
||||
let slots = if matches!(view.kind, Kind::Furnace | Kind::Brewing) && face.is_some() {
|
||||
view.slots_for_face(face)
|
||||
} else {
|
||||
@@ -482,7 +525,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// `HopperBlockEntity.tryMoveItems` with `suckInItems`.
|
||||
fn hopper_try_move_items(&mut self, pos: Pos) {
|
||||
pub(super) fn hopper_try_move_items(&mut self, pos: Pos) {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
let enabled = c.prop(state, "enabled") != Some("false");
|
||||
@@ -540,6 +583,23 @@ impl Game<'_> {
|
||||
let above = (pos.0, pos.1 + 1, pos.2);
|
||||
let c = self.containers();
|
||||
if let Some(source) = c.view(above, false) {
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return false;
|
||||
}
|
||||
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
|
||||
// its setChanged, which empties the composter, and so does the
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
|
||||
return moved;
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
|
||||
@@ -183,6 +183,24 @@ impl Game<'_> {
|
||||
self.maps.last_id = Some(last + 1);
|
||||
last + 1
|
||||
}
|
||||
/// The id the next new map gets, without taking it.
|
||||
pub(super) fn peek_map_id(&mut self) -> i32 {
|
||||
let id = self.next_map_id();
|
||||
self.maps.last_id = Some(id - 1);
|
||||
id
|
||||
}
|
||||
/// Maps made by exploration map trades: (id, target, zoom, decoration).
|
||||
pub(super) fn register_exploration_maps(&mut self, made: Vec<(i32, [i32; 3], u8, String)>) {
|
||||
let dimension = self.dimension_id().to_owned();
|
||||
for (id, at, zoom, _) in made {
|
||||
// MapItem.applyNewSavedData: tracking, unlimited tracking.
|
||||
let mut data = MapData::fresh(f64::from(at[0]), f64::from(at[2]), zoom, &dimension);
|
||||
data.unlimited = true;
|
||||
self.maps.data.insert(id, data);
|
||||
let last = self.maps.last_id.unwrap_or(-1).max(id);
|
||||
self.maps.last_id = Some(last);
|
||||
}
|
||||
}
|
||||
/// Registers `data` under a free map id.
|
||||
pub(super) fn new_map_id(&mut self, data: MapData) -> i32 {
|
||||
let id = self.next_map_id();
|
||||
@@ -514,6 +532,7 @@ impl Game<'_> {
|
||||
let quad = |corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], out: &mut Vec<Vertex>| {
|
||||
for (c, uv) in corners.into_iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(glam::Vec3::from_array(c)).to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -560,6 +579,7 @@ impl Game<'_> {
|
||||
let mut q = |corners: [[f32; 3]; 4], uv: [[f32; 2]; 4]| {
|
||||
for (c, uv) in corners.into_iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(glam::Vec3::from_array(c)).to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -628,6 +648,7 @@ impl Game<'_> {
|
||||
([-7.0, -7.0, 0.0], [r.u0, r.v0]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(glam::Vec3::from_array(c)).to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
|
||||
@@ -304,6 +304,7 @@ impl Game<'_> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
|
||||
c.position = Vec3::new(
|
||||
@@ -1113,6 +1114,7 @@ impl Game<'_> {
|
||||
let p = face.quad.positions[k];
|
||||
let uv = face.quad.uv[k];
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(glam::Vec3::from_array(p)).to_array(),
|
||||
uv: [s.u0 + uv[0] * (s.u1 - s.u0), s.v0 + uv[1] * (s.v1 - s.v0)],
|
||||
layer: 0,
|
||||
|
||||
@@ -197,6 +197,7 @@ impl Game<'_> {
|
||||
for (corner, uv) in corners.iter().zip(uvs) {
|
||||
let p = t.transform_point3(glam::Vec3::from(*corner));
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: overlay,
|
||||
|
||||
@@ -154,6 +154,7 @@ impl Game<'_> {
|
||||
];
|
||||
for ((x, y), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (at(x, y) + if background { away } else { glam::Vec3::ZERO })
|
||||
.to_array(),
|
||||
uv,
|
||||
|
||||
@@ -163,6 +163,7 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.game.world,
|
||||
binding: self.w.collision,
|
||||
subject: None,
|
||||
};
|
||||
no_collision_empty(&mut scene, bounds)
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ impl Game<'_> {
|
||||
}
|
||||
let m = &mut self.mobs[index];
|
||||
if !m.extra.age_locked {
|
||||
let secs = (-m.extra.age / 20) as f32 * 0.1;
|
||||
m.extra.age = (m.extra.age + (secs as i32) * 20).min(0);
|
||||
let secs = ((-m.extra.age / 20) as f32 * 0.1) as i32;
|
||||
horizon_client::mobs::ai::age_up(m, secs, true);
|
||||
}
|
||||
let sound = nautilus::eat_sound(m);
|
||||
self.use_nautilus_food(item);
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_client::{
|
||||
use winit::{event::MouseButton, keyboard::KeyCode, window::Fullscreen};
|
||||
|
||||
impl Game<'_> {
|
||||
fn options_path() -> std::path::PathBuf {
|
||||
pub(super) fn options_path() -> std::path::PathBuf {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("options.txt")
|
||||
@@ -121,11 +121,9 @@ impl Game<'_> {
|
||||
self.reload_packs();
|
||||
}
|
||||
Event::OpenPackFolder => {
|
||||
let dir = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("resourcepacks");
|
||||
let dir = super::packs::packs_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = std::process::Command::new("xdg-open").arg(&dir).spawn();
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Resource pack selection bound to the game: the pack screen lists and
|
||||
//! reloading after a change (atlas, fonts, GUI metadata, languages and item
|
||||
//! icons live; block textures and models from the next start).
|
||||
//! Resource pack selection bound to the game: the pack screen lists, and a
|
||||
//! full resource reload after a change, while playing.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud::HudAtlas,
|
||||
@@ -22,6 +21,19 @@ pub fn packs_dir() -> PathBuf {
|
||||
.unwrap_or_default()
|
||||
.join("resourcepacks")
|
||||
}
|
||||
/// `Util.getPlatform().openPath`: the system file manager on the folder.
|
||||
pub fn open_folder(dir: &Path) {
|
||||
let program = if cfg!(target_os = "windows") {
|
||||
"explorer"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else {
|
||||
"xdg-open"
|
||||
};
|
||||
if let Err(e) = std::process::Command::new(program).arg(dir).spawn() {
|
||||
eprintln!("Horizon: could not open {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root and the selection.
|
||||
pub fn startup_root(vanilla: &Path, selected: &[String]) -> PathBuf {
|
||||
let all = packs::discover(vanilla, &packs_dir());
|
||||
@@ -81,63 +93,67 @@ impl Game<'_> {
|
||||
o.pack_selection = self.settings.resource_packs.clone();
|
||||
o.pack_screen = None;
|
||||
}
|
||||
/// Rebuild the overlay and reload what can change while running.
|
||||
/// `Minecraft.reloadResourcePacks`: rebuild the overlay and reload every
|
||||
/// client resource from it, as vanilla's reload listeners do: block
|
||||
/// models and sprites (every section is meshed again,
|
||||
/// `LevelRenderer.allChanged`), the shared atlas (GUI, item, entity,
|
||||
/// particle and font textures), item models, sounds, languages, GUI
|
||||
/// metadata and the cloud and world border textures.
|
||||
pub(super) fn reload_packs(&mut self) {
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let root = startup_root(&vanilla, &self.settings.resource_packs);
|
||||
self.options.resources = root.clone();
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
match HudAtlas::build(&root) {
|
||||
Ok(mut atlas) => {
|
||||
atlas.font.uniform = self.settings.force_unicode;
|
||||
atlas.font.japanese = self.settings.japanese_glyphs;
|
||||
let same_size =
|
||||
atlas.width == self.res.atlas.width && atlas.height == self.res.atlas.height;
|
||||
if same_size {
|
||||
if let Some(texture) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.and_then(|r| r.hud.atlas_texture.clone())
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
r.queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&atlas.pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(atlas.width * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: atlas.width,
|
||||
height: atlas.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.res.atlas = atlas;
|
||||
match horizon_client::item_render::ItemRenderer::load(&root) {
|
||||
Ok(r) => self.res.item_renderer = std::sync::Arc::new(r),
|
||||
Err(e) => eprintln!("Horizon: item model reload failed: {e}"),
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"Horizon: resource pack atlas size changed; restart to apply GUI textures"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Horizon: resource pack reload failed: {e}"),
|
||||
if let Err(e) = self.reload_resources(&root) {
|
||||
eprintln!("Horizon: resource reload failed: {e}");
|
||||
}
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let assets = std::sync::Arc::new(self.res.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build(root)?;
|
||||
atlas.font.uniform = self.settings.force_unicode;
|
||||
atlas.font.japanese = self.settings.japanese_glyphs;
|
||||
let item_renderer = horizon_client::item_render::ItemRenderer::load(root)?;
|
||||
let visibility = horizon_client::models::load_visibility(root, assets.registry)?;
|
||||
let tables = std::sync::Arc::new(horizon_client::mesher::MeshTables::new(
|
||||
&assets,
|
||||
&self.res.facts,
|
||||
visibility,
|
||||
)?);
|
||||
// Everything loaded: switch over.
|
||||
self.options.resources = root.to_path_buf();
|
||||
let context = std::sync::Arc::new(horizon_client::streaming::WorkerContext {
|
||||
assets: assets.clone(),
|
||||
tables,
|
||||
biomes: self.res.context.biomes.clone(),
|
||||
light: self.res.context.light.clone(),
|
||||
});
|
||||
self.res.layer_names = assets
|
||||
.sprite_layers
|
||||
.iter()
|
||||
.map(|(name, layer)| (*layer, name.trim_start_matches("minecraft:").to_owned()))
|
||||
.collect();
|
||||
self.res.assets = assets;
|
||||
self.res.context = context.clone();
|
||||
self.res.atlas = atlas;
|
||||
self.res.item_renderer = std::sync::Arc::new(item_renderer);
|
||||
self.res.clouds = super::load_clouds(root);
|
||||
self.res.forcefield = super::load_forcefield(root);
|
||||
self.res.names = super::Names::load(root);
|
||||
horizon_client::ui::load_metadata(root);
|
||||
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
|
||||
self.audio.reload(root);
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.pending_uploads.clear();
|
||||
// New GPU textures: a renderer built from the reloaded resources; the
|
||||
// sections are uploaded again as they are meshed.
|
||||
if let Some(window) = self.window.clone() {
|
||||
self.renderer = None;
|
||||
self.renderer = Some(self.new_renderer(window)?);
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
let code = lang::code();
|
||||
lang::set(lang::Language::load(&[&root], &code));
|
||||
self.ui.chat.rescale(&self.res.atlas);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ impl Game<'_> {
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(bounds).unwrap_or(false) {
|
||||
return false;
|
||||
@@ -283,6 +284,7 @@ impl Game<'_> {
|
||||
let color = 0xFF00_0000 | k << 16 | k << 8 | k;
|
||||
for (p, uv) in corners.iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
|
||||
@@ -204,6 +204,7 @@ impl Game<'_> {
|
||||
age,
|
||||
no_gravity,
|
||||
drop_item,
|
||||
fall_distance: 0.0,
|
||||
saved: uuid.map(|u| Box::new((u, tag.clone()))),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ impl Game<'_> {
|
||||
scene: SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
},
|
||||
facts: &self.res.facts,
|
||||
context,
|
||||
|
||||
@@ -61,9 +61,6 @@ pub(super) struct PotionState {
|
||||
pub(super) burning: Vec<(u32, i32)>,
|
||||
/// Enchantment attribute modifiers on the player: (attribute, id, amount, operation).
|
||||
pub(super) enchant_modifiers: Vec<(String, String, f64, String)>,
|
||||
/// The level's trade-set `RandomSequence`s, by sequence id.
|
||||
pub(super) trade_randoms:
|
||||
std::collections::HashMap<String, horizon_core::random::XoroshiroRandom>,
|
||||
}
|
||||
|
||||
impl PotionState {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Powder snow around the player: its snowflakes, fire put out by it, and the
|
||||
//! bucket that picks it up (`BucketItem.use` on a `BucketPickup` block). The
|
||||
//! sinking, slowing, climbing and freezing are `PlayerMotion`'s
|
||||
//! (`crate::motion`); placing it is the powder snow bucket's `BlockItem.place`.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ClientPacketListener.handleParticleEvent` for the one ALTERNATIVE
|
||||
/// snowflake `PowderSnowBlock.entityInside` sends: no spread, its speed.
|
||||
pub(super) fn snowflake(&mut self, position: Vec3, velocity: Vec3) {
|
||||
let r = self.particles.random();
|
||||
// nextDouble() * dist for each axis, the distances being 0.
|
||||
let offset = [
|
||||
r.next_double() * 0.0,
|
||||
r.next_double() * 0.0,
|
||||
r.next_double() * 0.0,
|
||||
];
|
||||
let options = ParticleOptions::simple(ParticleType::Snowflake);
|
||||
self.particles.add(
|
||||
&options,
|
||||
[
|
||||
position.x + offset[0],
|
||||
position.y + offset[1],
|
||||
position.z + offset[2],
|
||||
],
|
||||
[velocity.x, velocity.y, velocity.z],
|
||||
);
|
||||
}
|
||||
/// The EXTINGUISH effect of the powder snow the player went into this
|
||||
/// tick: burning, it destroys those blocks (a player always may), then
|
||||
/// its fire goes out (`Entity.clearFire`) with the extinguish sound.
|
||||
pub(super) fn powder_snow_fire(&mut self) -> Result<()> {
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let entered = std::mem::take(&mut player.powder_snow_entered);
|
||||
let at = [player.position.x, player.position.y, player.position.z];
|
||||
if entered.is_empty() || self.creatures.player_fire <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
for pos in entered {
|
||||
self.with_level(|b, level, ticks| b.destroy_block(level, ticks, pos, false));
|
||||
self.world.modified.insert((pos.x >> 4, pos.z >> 4));
|
||||
}
|
||||
self.process_level_events()?;
|
||||
self.creatures.player_fire = 0;
|
||||
// Entity.playEntityOnFireExtinguishedSound
|
||||
let r = self.particles.random();
|
||||
let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4;
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.extinguish_fire",
|
||||
Some(at),
|
||||
0.7,
|
||||
pitch,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
/// Whether the block at `pos` is powder snow.
|
||||
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
|
||||
&*self
|
||||
.res
|
||||
.facts
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name
|
||||
== "minecraft:powder_snow"
|
||||
}
|
||||
/// `BucketItem.use` with an empty bucket on powder snow:
|
||||
/// `PowderSnowBlock.pickupBlock` (air, level event 2001), the used
|
||||
/// statistic, the fill sound, FLUID_PICKUP, `ItemUtils.createFilledResult`
|
||||
/// and the filled_bucket trigger.
|
||||
pub(super) fn pick_up_powder_snow(&mut self, pos: [i32; 3]) -> Result<()> {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let picked = self.with_level(|b, level, ticks| b.bucket_pickup(level, ticks, at));
|
||||
self.process_level_events()?;
|
||||
let Some(filled) = picked else {
|
||||
return Ok(());
|
||||
};
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.stats.add("used", "minecraft:bucket", 1);
|
||||
if let Some(eye) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])
|
||||
{
|
||||
self.audio.play(
|
||||
"minecraft:item.bucket.fill_powder_snow",
|
||||
Some(eye),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
self.post_block_game_event("minecraft:fluid_pickup", pos, Some(Who::Player), None);
|
||||
let filled = ItemStack::new(filled, 1);
|
||||
self.filled_result(filled.clone());
|
||||
self.trigger_item("minecraft:filled_bucket", &filled);
|
||||
self.swing_arm();
|
||||
Ok(())
|
||||
}
|
||||
/// `ItemUtils.createFilledResult(held, player, filled)` for the held stack.
|
||||
fn filled_result(&mut self, filled: ItemStack) {
|
||||
if self.creative {
|
||||
// Kept, and the filled one added unless the inventory has it.
|
||||
let has = self
|
||||
.inventory
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| s.same_item_same_components(&filled));
|
||||
if !has {
|
||||
let _ = self.give(filled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let held = &mut self.inventory[self.selected];
|
||||
let last = held.as_ref().is_none_or(|s| s.count <= 1);
|
||||
if last {
|
||||
*held = Some(filled);
|
||||
return;
|
||||
}
|
||||
if let Some(s) = held.as_mut() {
|
||||
s.count -= 1;
|
||||
}
|
||||
if let Some(rest) = self.give(filled) {
|
||||
self.player_drop(rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ impl Game<'_> {
|
||||
pub(super) fn recipe_key(&mut self, event: &winit::event::KeyEvent) {
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let shift = self.has_shift_down();
|
||||
let Some(search) = self.recipe_ui.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -620,8 +620,23 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
fn shift_down(&self) -> bool {
|
||||
use winit::keyboard::KeyCode;
|
||||
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight)
|
||||
self.has_shift_down()
|
||||
}
|
||||
/// The open screen's book for `hasClickedOutside`: (visible, selected tab
|
||||
/// hovered); None when the screen has no book.
|
||||
pub(super) fn recipe_book_area(&self, batch: &HudBatch) -> Option<(bool, bool)> {
|
||||
let book = self.active_book()?;
|
||||
if !self.book_open(book) {
|
||||
return Some((false, false));
|
||||
}
|
||||
let (xo, yo) = self.book_origin(batch);
|
||||
let tabs = &self.recipe_ui.view.tabs;
|
||||
let selected = self.recipe_ui.tab.unwrap_or(Tab::Search);
|
||||
let hovered = tabs.iter().position(|t| *t == selected).is_some_and(|n| {
|
||||
let (x, y) = self.tab_pos(xo, yo, n);
|
||||
inside(self.cursor, x, y, 35.0, 27.0)
|
||||
});
|
||||
Some((true, hovered))
|
||||
}
|
||||
/// The grid, result and furnace slots clicks on which end a ghost recipe.
|
||||
fn is_book_slot(&self, book: BookType, slot: usize) -> bool {
|
||||
|
||||
@@ -287,7 +287,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send(&self, packet: S) {
|
||||
pub(super) fn send(&self, packet: S) {
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.send(packet);
|
||||
}
|
||||
@@ -315,12 +315,20 @@ impl Game<'_> {
|
||||
}
|
||||
fn to_stack(&self, s: &Option<Stack>) -> Option<ItemStack> {
|
||||
let s = s.as_ref()?;
|
||||
Some(ItemStack {
|
||||
let mut out = ItemStack {
|
||||
item: self.item_name(s.item),
|
||||
count: s.count.max(0) as u32,
|
||||
damage: s.damage().max(0) as u32,
|
||||
extra: None,
|
||||
})
|
||||
};
|
||||
use horizon_protocol::item::{Component, component};
|
||||
if let Some(Component::Int(rgb)) = s.component(component::DYED_COLOR) {
|
||||
out.data_mut().dyed_color = Some(*rgb);
|
||||
}
|
||||
if let Some(Component::Int(id)) = s.component(component::MAP_ID) {
|
||||
out.data_mut().map_id = Some(*id);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
|
||||
let s = s.as_ref()?;
|
||||
@@ -659,6 +667,13 @@ impl Game<'_> {
|
||||
37..=45 => Some(s - 37),
|
||||
_ => None,
|
||||
},
|
||||
"minecraft:merchant" => match s {
|
||||
0 | 1 => Some(super::stations::INPUT + s),
|
||||
2 => Some(super::stations::RESULT),
|
||||
3..=29 => Some(9 + s - 3),
|
||||
30..=38 => Some(s - 30),
|
||||
_ => None,
|
||||
},
|
||||
"minecraft:furnace" | "minecraft:blast_furnace" | "minecraft:smoker" => match s {
|
||||
0..=2 => Some(300 + s),
|
||||
3..=29 => Some(9 + s - 3),
|
||||
@@ -1066,6 +1081,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::OpenScreen(p) => self.remote_open_screen(p),
|
||||
C::MerchantOffers(p) => self.remote_merchant_offers(p),
|
||||
C::ContainerClose(_) => {
|
||||
if self.screen == Screen::Inventory {
|
||||
self.remote_close_local();
|
||||
@@ -1553,6 +1569,7 @@ impl Game<'_> {
|
||||
let mut player = PlayerMotion::new(self.motion, position)?;
|
||||
player.velocity = delta;
|
||||
self.player = Some(player);
|
||||
self.sway = Default::default();
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
@@ -2266,8 +2283,7 @@ impl Game<'_> {
|
||||
};
|
||||
let slot = (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal));
|
||||
let Some(slot) = slot else { return };
|
||||
let shift =
|
||||
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
|
||||
let shift = self.has_shift_down();
|
||||
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
|
||||
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
|
||||
self.send(S::ContainerClick(play::ContainerClick {
|
||||
@@ -2280,6 +2296,38 @@ impl Game<'_> {
|
||||
carried,
|
||||
}));
|
||||
}
|
||||
/// A click outside a server's window (slot -999): predicted like
|
||||
/// `MultiPlayerGameMode.handleContainerInput` (the carried stack after
|
||||
/// `doClick`; the server drops the stack and swings the hand).
|
||||
pub(super) fn remote_throw(&mut self, right: bool, input: horizon_client::menu::Input) {
|
||||
use horizon_client::menu::Input;
|
||||
let container = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.menu.as_ref())
|
||||
.map_or(0, |m| m.0);
|
||||
if input == Input::Pickup
|
||||
&& let Some(c) = self.carried.as_mut()
|
||||
{
|
||||
if right && c.count > 1 {
|
||||
c.count -= 1;
|
||||
} else {
|
||||
self.carried = None;
|
||||
}
|
||||
self.swing_arm();
|
||||
}
|
||||
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
|
||||
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
|
||||
self.send(S::ContainerClick(play::ContainerClick {
|
||||
container,
|
||||
state,
|
||||
slot: -999,
|
||||
button: i8::from(right),
|
||||
input: if input == Input::Throw { 4 } else { 0 },
|
||||
changed: Vec::new(),
|
||||
carried,
|
||||
}));
|
||||
}
|
||||
pub(super) fn remote_close_container(&mut self) {
|
||||
let id = self
|
||||
.remote
|
||||
@@ -2293,6 +2341,13 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = None;
|
||||
}
|
||||
if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == super::stations::Kind::Merchant)
|
||||
{
|
||||
self.station = None;
|
||||
}
|
||||
self.craft_size = 2;
|
||||
if let Some(key) = self.container.take() {
|
||||
// Server containers stay renderable but lose their copied slots.
|
||||
@@ -2303,6 +2358,52 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A server's trading window (MerchantScreen over a ClientSideMerchant).
|
||||
fn remote_open_merchant(&mut self, container: i32, title: String) {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = Some((container, "minecraft:merchant".to_owned()));
|
||||
}
|
||||
let session = horizon_client::merchant::Session::new(Vec::new(), true);
|
||||
self.open_remote_merchant(session, (!title.is_empty()).then_some(title));
|
||||
}
|
||||
/// ClientboundMerchantOffersPacket.
|
||||
fn remote_merchant_offers(&mut self, p: play::MerchantOffers) {
|
||||
if self.open_menu_name(p.container) != "minecraft:merchant" {
|
||||
return;
|
||||
}
|
||||
let cost = |g: &Self, c: &play::NetCost| horizon_client::merchant::ItemCost {
|
||||
stack: ItemStack::new(&g.item_name(c.item), c.count.max(0) as u32),
|
||||
};
|
||||
let offers: Vec<_> = p
|
||||
.offers
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let mut offer = horizon_client::merchant::MerchantOffer {
|
||||
base_cost_a: cost(self, &o.cost_a),
|
||||
cost_b: o.cost_b.as_ref().map(|b| cost(self, b)),
|
||||
result: self.to_stack(&Some(o.result.clone()))?,
|
||||
uses: o.uses,
|
||||
max_uses: o.max_uses,
|
||||
reward_exp: true,
|
||||
special_price_diff: o.special_price,
|
||||
demand: o.demand,
|
||||
price_multiplier: o.price_multiplier,
|
||||
xp: o.xp,
|
||||
};
|
||||
// setToOutOfStock.
|
||||
if o.out_of_stock {
|
||||
offer.uses = offer.max_uses;
|
||||
}
|
||||
Some(offer)
|
||||
})
|
||||
.collect();
|
||||
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
|
||||
m.session.offers = offers;
|
||||
m.session.level = p.level;
|
||||
m.session.xp = p.xp;
|
||||
m.session.villager = p.show_progress;
|
||||
}
|
||||
}
|
||||
/// ClientboundOpenScreenPacket for the menus Horizon draws.
|
||||
fn remote_open_screen(&mut self, p: play::OpenScreen) {
|
||||
let menu = self
|
||||
@@ -2320,6 +2421,19 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.and_then(|r| r.use_target)
|
||||
.map(|t| (t[0], t[1], t[2]));
|
||||
if menu == "minecraft:merchant" {
|
||||
// A trading window: its offers follow in a MerchantOffers packet.
|
||||
let title = plain(
|
||||
&p.title,
|
||||
&self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
self.remote_open_merchant(p.container, title);
|
||||
return;
|
||||
}
|
||||
let entity = match menu.as_str() {
|
||||
"minecraft:crafting" => None,
|
||||
"minecraft:furnace" | "minecraft:blast_furnace" | "minecraft:smoker" => {
|
||||
|
||||
@@ -83,9 +83,21 @@ impl Game<'_> {
|
||||
let count = num(8, 1.0)? as u32;
|
||||
let options = match ty {
|
||||
ParticleType::Dust => ParticleOptions::dust([1.0, 0.0, 0.0], 1.0),
|
||||
ParticleType::Block | ParticleType::FallingDust | ParticleType::DustPillar => {
|
||||
ParticleType::Block
|
||||
| ParticleType::FallingDust
|
||||
| ParticleType::DustPillar
|
||||
| ParticleType::BlockMarker => {
|
||||
ParticleOptions::block(ty, "block/stone".into(), 0x808080)
|
||||
}
|
||||
ParticleType::Item => self.item_particle(
|
||||
&horizon_client::items::ItemStack::new("minecraft:apple", 1),
|
||||
),
|
||||
ParticleType::ItemSlime
|
||||
| ParticleType::ItemSnowball
|
||||
| ParticleType::ItemCobweb => {
|
||||
self.ensure_item_particle_sprites();
|
||||
ParticleOptions::simple(ty)
|
||||
}
|
||||
_ => ParticleOptions::simple(ty),
|
||||
};
|
||||
// ServerLevel.sendParticles: count 0 uses the delta as velocity.
|
||||
|
||||
@@ -83,6 +83,7 @@ impl Game<'_> {
|
||||
],
|
||||
biome,
|
||||
render_distance_chunks: self.streaming.radius,
|
||||
cloud_range_chunks: self.settings.cloud_range,
|
||||
camera_y: camera.y,
|
||||
min_y: MIN_Y,
|
||||
flat: false,
|
||||
@@ -160,8 +161,8 @@ impl Game<'_> {
|
||||
}
|
||||
_ => Vec::new(),
|
||||
},
|
||||
ticks_frozen: 0,
|
||||
percent_frozen: 0.0,
|
||||
ticks_frozen: p.ticks_frozen,
|
||||
percent_frozen: p.percent_frozen(),
|
||||
partial,
|
||||
portal: [fx.o_portal, fx.portal],
|
||||
nausea: 0.0,
|
||||
|
||||
@@ -54,7 +54,10 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
|
||||
impl Game<'_> {
|
||||
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
|
||||
pub(super) fn horizon_pause(&self) -> bool {
|
||||
self.screen == Screen::Paused && self.title.is_none() && horizon_client::ui_theme::horizon()
|
||||
self.screen == Screen::Paused
|
||||
&& self.pause_menu
|
||||
&& self.title.is_none()
|
||||
&& horizon_client::ui_theme::horizon()
|
||||
}
|
||||
fn pause_labels(&self) -> (String, &'static str) {
|
||||
let world = if self.remote.is_some() {
|
||||
|
||||
@@ -46,13 +46,13 @@ impl Game<'_> {
|
||||
use winit::keyboard::KeyCode;
|
||||
let saved = self.cursor;
|
||||
self.cursor = (x, y);
|
||||
let had_shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let had_shift = self.physical_keys.contains(&KeyCode::ShiftLeft);
|
||||
if shift {
|
||||
self.keys.insert(KeyCode::ShiftLeft);
|
||||
self.physical_keys.insert(KeyCode::ShiftLeft);
|
||||
}
|
||||
self.click_inventory(right);
|
||||
if shift && !had_shift {
|
||||
self.keys.remove(&KeyCode::ShiftLeft);
|
||||
self.physical_keys.remove(&KeyCode::ShiftLeft);
|
||||
}
|
||||
self.cursor = saved;
|
||||
}
|
||||
|
||||
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user