mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
259
Commits
No files matched your search
+22
-15
@@ -8,12 +8,7 @@ permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Linux only. macOS only when started by hand: gh workflow run checks --ref <branch>.
|
||||
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["ubuntu-latest", "macos-26"]' || '["ubuntu-latest"]') }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -27,14 +22,30 @@ jobs:
|
||||
python tools/check_public_tree.py --history
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- name: Install Rust toolchain and the ALSA headers the client's audio links against
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- name: Check formatting and lint
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
run: cargo +stable test --workspace
|
||||
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
|
||||
# (label horizon-game) holding the maintainer's own copy: the official launcher's
|
||||
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
|
||||
# HORIZON_SERVER_JAR. Start them by hand: gh workflow run checks --ref <branch>.
|
||||
vanilla-parity:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -374,12 +385,8 @@ jobs:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_float_log_parity.py
|
||||
portable-mod:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Linux only. macOS only when started by hand: gh workflow run checks --ref <branch>.
|
||||
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["ubuntu-latest", "macos-26"]' || '["ubuntu-latest"]') }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
@@ -466,7 +473,7 @@ jobs:
|
||||
- name: Upload original mod and pack manifests only
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-${{ matrix.os }}
|
||||
name: horizon-linux
|
||||
path: |
|
||||
dist/*.mrpack
|
||||
dist/horizon-*.jar
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: release builds
|
||||
# Started by hand: gh workflow run release.yml --ref <branch>
|
||||
# Builds the Windows x64 client and uploads the playable folder as an artifact.
|
||||
# Linux and macOS builds are made on the development machines.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- name: Build the client
|
||||
# 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
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-windows-x64
|
||||
path: dist/horizon-windows-x64
|
||||
@@ -15,6 +15,11 @@ type_complexity = "allow"
|
||||
needless_range_loop = "allow"
|
||||
large_enum_variant = "allow"
|
||||
|
||||
# Line tables keep file:line in backtraces; full debug info made every
|
||||
# example and test binary hundreds of megabytes and slow to link.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -1,336 +1,199 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.jpg" alt="Horizon: Minecraft Java Edition 26.3, rewritten from scratch in Rust" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
||||
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
|
||||
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-89%25-F0795A" alt="Vanilla parity 89%">
|
||||
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#getting-it"><b>Get started</b></a> ·
|
||||
<a href="#screenshots"><b>Screenshots</b></a> ·
|
||||
<a href="#project-status"><b>Parity status</b></a> ·
|
||||
<a href="docs/native-client.md"><b>Native client guide</b></a>
|
||||
</p>
|
||||
|
||||
# Horizon
|
||||
|
||||
Horizon is a Rust rewrite of Minecraft Java **26.3** targeting one-to-one vanilla gameplay parity, alongside client performance research. Not affiliated with Mojang or Microsoft.
|
||||
A Rust rewrite of Minecraft: Java Edition 26.3 that aims for one-to-one vanilla parity, checked against the game itself with differential tests.
|
||||
|
||||
**Status:** in development. The native client is playable on Linux, macOS (Apple Silicon) and Windows; Java parity is 89% (see [Project status](#project-status)).
|
||||
|
||||
> 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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Captured in the Rust client itself, with vanilla assets and no mods or shaders.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/media/sunset.jpg" alt="Sunset over a savanna river valley and a plains village"></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>
|
||||
</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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/underwater.jpg" 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>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## What it does
|
||||
|
||||
Horizon reimplements the Minecraft Java client and its game logic in Rust, one subsystem at a time, and compares each piece with the vanilla game. Each `tools/test_*_parity.py` harness drives the same inputs through Horizon and through the vanilla client and server bytecode from your own copy of the game, then checks that the outputs are identical. It is for players and developers interested in game reimplementation, and for anyone who wants to see how far a behaviour-exact native rewrite can go.
|
||||
|
||||
The [native client](docs/native-client.md) opens on a vanilla-style title screen with world creation and selection, and generates worlds with the Rust port of the vanilla generators: noise terrain, aquifers, surface rules, carvers and placed features, all checked against vanilla. It streams and lights chunks on worker threads and renders every vanilla block model with smooth lighting, biome tints, the vanilla sky, sun, moon, stars and clouds. Gameplay includes:
|
||||
|
||||
**The Rust game is now playable.** The [native client](docs/native-client.md) opens on a vanilla-style title screen with world creation and selection, and generates worlds with the Rust port of the vanilla overworld generator: noise terrain, aquifers, surface rules, carvers, and the placed features ported so far, all checked against vanilla. It streams and lights chunks on worker threads and renders every vanilla block model with smooth lighting, biome tints, the vanilla sky, sun, moon, stars and clouds. Gameplay so far:
|
||||
- validated player physics, health and hunger;
|
||||
- mining with vanilla tool rules and loot tables;
|
||||
- placing, crafting, chests and furnaces;
|
||||
- dropped items and particles;
|
||||
- sounds;
|
||||
- passive and hostile mobs with combat, including guardians, shulkers, illagers, ravagers, the wither and the ender dragon fight;
|
||||
- villagers that take jobs, trade, restock, meet at the bell and sleep in beds;
|
||||
- dropped items, particles and sounds;
|
||||
- passive and hostile mobs with combat and vanilla AI, including guardians, shulkers, illagers, ravagers, the wither, the ender dragon fight, the breeze, the creaking, the sniffer, the happy ghast, llamas and the copper golem;
|
||||
- villagers that take jobs, trade, restock, meet at the bell and sleep in beds, on a port of vanilla's brain;
|
||||
- raids and pillager patrols;
|
||||
- dispensers, crafters, the loom and the recipe book;
|
||||
- every command, scoreboards and teams, the player list, subtitles and the HUD overlays;
|
||||
- boats, minecarts, item frames, paintings and armor stands;
|
||||
- shields, maps, books, fireworks, beacons, advancements and statistics;
|
||||
- Anvil saves.
|
||||
- Anvil saves, joining vanilla servers, and opening a world to LAN.
|
||||
|
||||
Parity is **not** complete. Still missing: the warden and the sculk family, the breeze, sniffer, creaking, happy ghast, llamas and a few other mobs, raids and pillager patrols, villager breeding and gossip, the loom, crafter and recipe book, scoreboards, subtitles, and spawned mob gear enchantments. Multiplayer joins vanilla servers but Horizon cannot host one. See [Rust rewrite status](docs/rust-parity.md).
|
||||
## Getting it
|
||||
|
||||
### Java parity: 80%
|
||||
Builds for **Linux x86-64**, **macOS Apple Silicon** and **Windows x64** are attached to the [releases](https://github.com/ludovic111/horizon/releases). Each folder holds the game, the resource preparation tool and a `PLAY.txt`; the first start prepares the resources from your own copy of the game, as below. You can also build from source.
|
||||
|
||||
**Platform.** A GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Development and the vanilla harnesses run on Linux.
|
||||
|
||||
**Requirements.** Python 3.10 or newer and JDK 25 (for resource preparation). To build from source: Rust 1.90 or newer, and on Linux the ALSA development headers (`libasound2-dev` on Debian and Ubuntu).
|
||||
|
||||
### Bring your own copy
|
||||
|
||||
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
|
||||
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
|
||||
2. **Server jar:** download the 26.3 server jar yourself, from [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release, or otherwise from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json`. Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
|
||||
|
||||
### Build and run
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ludovic111/horizon.git
|
||||
cd horizon
|
||||
python3 tools/prepare_native_client.py --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
|
||||
python3 tools/fetch_sounds.py # optional: sounds from your launcher install
|
||||
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.
|
||||
|
||||
## Usage
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`), controls, scripting through `horizon-ctl`, and packaging for another of your own Linux machines are described in [the native client guide](docs/native-client.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Native client](docs/native-client.md): running, options, controls, scripting and architecture.
|
||||
- [Rust rewrite status](docs/rust-parity.md): completion criteria and per-area scope.
|
||||
- Parity notes: [block behaviours and ticking](docs/parity/blocks.md), [section meshing](docs/parity/meshing.md).
|
||||
- [Decompilation and Rust research lab](docs/research-lab.md): the local game inspection workspace and the first research crates.
|
||||
- [Decompilation status](docs/decompilation-status.md).
|
||||
- [Java performance packs](docs/java-packs.md): the earlier, no longer developed Java track.
|
||||
|
||||
## Project status
|
||||
|
||||
Parity is **not** complete. The table below is the project's own audit of each subsystem. Still missing: the warden's brain and the sculk shrieker summoning it, villager breeding and gossip, spawned mob gear enchantments, the resource pack screen, a dedicated server mode, and worlds older than 1.17.
|
||||
|
||||
### Java parity: 89%
|
||||
|
||||
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
|
||||
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
|
||||
and verified against vanilla by differential tests). The percentage is the
|
||||
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
2026-10-10 against the code, not the docs: of the 232 `tools/test_*_parity.py`
|
||||
differential harnesses, 223 pass, 8 fail and the item model harness runs out of
|
||||
memory; the failures are new harnesses that still find divergences.
|
||||
2026-10-12 against the harness results: 270 harnesses pass. Two still differ
|
||||
from vanilla in places: `mob_ai` (71 of 72 scenarios; a tempted ghastling) and
|
||||
`sculk` (427 of 435; shrieker warnings and warden summoning).
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 0.75 | Game loop and options match vanilla; camera, auto-jump, toggle sprint/sneak and the frame limiter not compared |
|
||||
| Assets and resources | 0.75 | Item model pipeline not yet used for all GUI icons; item model harness does not finish; no pack screen |
|
||||
| Network protocol and multiplayer | 0.75 | Opens worlds to LAN, but hosting is not tested against a vanilla client; no dedicated server mode |
|
||||
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
|
||||
| Assets and resources | 0.75 | Item models match vanilla over 196,290 stacks and the client draws every item through them; no resource pack screen |
|
||||
| Network protocol and multiplayer | 0.75 | Joins vanilla servers and opens worlds to LAN; server.properties, eula.txt and the user lists match vanilla, but there is no dedicated server mode and LAN hosting is not tested against a vanilla client |
|
||||
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
|
||||
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
|
||||
| Rendering | 0.75 | Entity model harness fails; block entity renderers and HUD overlays not compared |
|
||||
| Rendering | 1 | None: entity models, block entity renderers (407 cases, End portals, gateways and the loom's banner preview included) and their animations (296 cases, End gateway beams included), the HUD overlays (2,200 states), the recipe book (600 states), the player list, subtitles and the world border match vanilla |
|
||||
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
|
||||
| World generation | 0.75 | Whole-chunk harness still finds single-block differences near camps, shipwrecks and villages |
|
||||
| Region files and saves | 0.75 | Save round-trip harness fails; older DataVersions are not upgraded |
|
||||
| Entities and physics | 0.75 | Attributes and dimensions match; AI, loot and spawn rules not compared; drowned, villagers, traders and the wither unverified |
|
||||
| Interaction and inventory | 0.75 | Menu click harness fails |
|
||||
| Menus, HUD and chat | 0.75 | Command parse harness fails; no /item modify, /spectate, /waypoint or /dialog; no tab list or world border rendering |
|
||||
| Audio | 0.75 | Sound engine, music and ambience not compared with vanilla |
|
||||
| Block behaviours and ticking | 0.75 | Block update, random tick and redstone harnesses fail; a few dispenser behaviours missing |
|
||||
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
|
||||
| Region files and saves | 0.75 | Saves round-trip with vanilla, and worlds from 1.17 to 26.2 upgrade as vanilla's DataFixers do; worlds older than 1.17 are not upgraded |
|
||||
| Entities and physics | 0.75 | Attributes, dimensions, death loot, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900 cases) and mob AI (71 of 72 scenarios, villager brains included) match; the warden's brain, villager breeding and gossip, and spawned mob gear are missing |
|
||||
| Interaction and inventory | 1 | None: menu clicks match over 8,000 click sequences, the loom and the crafter included; recipe book placement (6,000 cases), its buttons (1,200), the saved book (1,500) and the loom and crafter screens (1,600) match vanilla |
|
||||
| Menus, HUD and chat | 0.75 | Every command runs as vanilla (758 commands over 39,685 parsed inputs), scoreboards and teams match (81,000 operations), and the player list matches over 844,758 draw calls; the focused chat is not compared and player list heads use default skins |
|
||||
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
|
||||
| Block behaviours and ticking | 0.75 | Block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts) and block entity removal (containers spill, 4,000 cases) match vanilla; 8 of 435 sculk scenarios differ (shrieker warnings and warden summoning) and the copper golem's chest sorting is not compared |
|
||||
|
||||
### Next steps
|
||||
### What's new since the previous version
|
||||
|
||||
1. Fix the divergences the failing harnesses report: block updates, random ticks, redstone, command parsing, menu clicks, save round-trip, whole chunks and entity models.
|
||||
2. Let the item model harness finish, and draw every GUI icon through the item model pipeline.
|
||||
3. An audio harness: sound engine attenuation, weighted sound events, music and ambience.
|
||||
4. Mob AI, loot and spawn-rule harnesses, and an oracle for drowned, villagers, traders and the wither.
|
||||
5. The missing commands, the tab list and world border rendering.
|
||||
6. Test LAN hosting with a vanilla client, and upgrade saves from older DataVersions.
|
||||
- **Mob AI matches vanilla**, compared tick by tick in 72 scenarios, with villagers on a port of vanilla's brain (memories, sensors, schedules and behaviours).
|
||||
- **New mobs:** the breeze and wind charges, the creaking and the creaking heart, the sniffer, the happy ghast and ghastlings, llamas and trader llamas, and the copper golem; the warden's model, animations and emissive layers.
|
||||
- **Raids and pillager patrols**, with the Raid Omen, the waves and the Hero of the Village effect.
|
||||
- **Dispensers, crafters, the loom and the recipe book**, and containers spill their items when their block is replaced.
|
||||
- **Every command runs as vanilla**, including /item modify, /spectate, /waypoint, /dialog and /place.
|
||||
- **Scoreboards and teams** in gameplay (criteria, friendly fire, collision, name tags), the **player list**, **subtitles**, every **HUD overlay**, **block entity animations** (chests, shulker boxes, bells, conduits, heads, the enchanting table's book) and **End portals and gateways** drawn with vanilla's shader.
|
||||
- **Worlds from 1.17 onwards** open and are upgraded as vanilla does; server.properties, eula.txt and the server user lists are read and written as vanilla does.
|
||||
- **Builds for Linux, macOS (Apple Silicon) and Windows.**
|
||||
|
||||
### Known issues
|
||||
|
||||
- **Render distance 2 never finishes loading:** a world opened with the render distance at 2 chunks stays on "Loading terrain…". Use a render distance of 3 or more (in Options > Video, or `--render-distance 12` on the command line).
|
||||
|
||||
### Roadmap
|
||||
|
||||
1. Fix loading at render distance 2; finish the warden's brain and the sculk shrieker summoning it, and the tempted ghastling.
|
||||
2. Villager breeding and gossip, and spawned mob gear enchantments (both started on their branches).
|
||||
3. A dedicated server mode, and LAN hosting tested with a vanilla client.
|
||||
4. Save upgrades from before 1.17, and the resource pack screen.
|
||||
5. Platform-independent float math, so every harness also gives vanilla's results on macOS.
|
||||
|
||||
The rewrite is complete when it reaches one-to-one parity and packaged native builds pass the launch checks in [Rust rewrite status](docs/rust-parity.md#completion-criteria-and-playable-builds).
|
||||
|
||||
### After the rewrite
|
||||
|
||||
Once Horizon matches vanilla, the plan is to build on that exact 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome. Before opening a pull request, run the offline checks:
|
||||
|
||||
```sh
|
||||
python3 tools/prepare_native_client.py --prepare # needs JDK 25; extracts private vanilla resources
|
||||
python3 tools/fetch_sounds.py # optional: vanilla sounds
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
|
||||
**Target machine:** the Rust rewrite targets one machine, **ludodesktop**: Ubuntu 26.04, Intel i7-13700KF, NVIDIA RTX 4080, GNOME on Wayland. The final deliverable is a playable native Linux build validated there. macOS (including the M2 Pro Mac) and Windows are out of scope. Track implementation and acceptance criteria in [Rust rewrite status](docs/rust-parity.md).
|
||||
|
||||
**Java performance packs (no longer developed):** the earlier track of pinned Java performance packs, distant terrain, an original frame/tick recorder and client/server starter downloads. No release downloads are published; build the packs and starters locally with `python3 tools/build_release.py` (see [Build and run](#build-and-run)). They are no longer a development target.
|
||||
|
||||
## Java packs: Mac starter (out of scope)
|
||||
|
||||
Build **horizon-mac-starter-0.21.0.zip** with `python3 tools/build_release.py` (it lands in `dist/`), extract it and open `Mac-Start.command`. It verifies and installs official universal Prism Launcher in `~/Applications` if needed, then opens the balanced pack import. Sign into Microsoft **inside Prism yourself**. This project needs no credentials.
|
||||
|
||||
Use native **Java 25 ARM64**, maximum heap **6144 MiB**, minimum **512 MiB** on your 16 GB M2 Pro. Prism can download Java automatically. Start at 1280×800 or 1920×1200; full Retina resolution is a larger GPU workload. [START-HERE.txt](packs/START-HERE.txt) has details. The same `.mrpack` files import into Prism on Windows and Linux.
|
||||
|
||||
If Prism is already installed, follow the [Mac import and first comparison guide](docs/mac-first-test.md).
|
||||
|
||||
Core and balanced also pass [macOS title/demo-world rendering probes](docs/client-graphics-smoke.md)
|
||||
on the hosted ARM64 software renderer, including readback, saves and clean exits.
|
||||
Physical M2 GPU performance and macOS 27 remain to be tested.
|
||||
|
||||
| Profile | Full-detail chunks | Distant LOD radius | Suggested maximum heap |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| baseline | 12 | none | 6144 MiB |
|
||||
| core-performance | 12 | none | 6144 MiB |
|
||||
| m2-balanced | 12 | 128 | 6144 MiB |
|
||||
| adaptive (experiment) | 12 | 128 | 6144 MiB |
|
||||
| worker-pacing (experiment) | 12 | 128 | 6144 MiB |
|
||||
| adaptive-pacing (experiment) | 12 | 128 | 6144 MiB |
|
||||
| queue-wait (experimental) | 12 | 128 | 6144 MiB |
|
||||
| adaptive-queue (experimental) | 12 | 128 | 6144 MiB |
|
||||
| dispatch-retry (experimental) | 12 | 128 | 6144 MiB |
|
||||
| tick-dedup (experimental) | 12 | 128 | 6144 MiB |
|
||||
| culling-empty (experimental) | 12 | 128 | 6144 MiB |
|
||||
| quad-reuse (experimental) | 12 | 128 | 6144 MiB |
|
||||
| terrain-position (experimental) | 12 | 128 | 6144 MiB |
|
||||
| culling-rays (experimental) | 12 | 128 | 6144 MiB |
|
||||
| foreground-first (combined experiment) | 12 | 128 | 6144 MiB |
|
||||
| foreground-vulkan (combined experiment) | 12 | 128 | 6144 MiB |
|
||||
| low-memory | 8 | none | 3072 MiB |
|
||||
| far-distance | 12 | 256 | 8192 MiB, with sufficient physical RAM |
|
||||
| cinematic | 12 | 128 | 6144 MiB |
|
||||
| vulkan | 12 | 128 | 6144 MiB |
|
||||
|
||||
Balanced combines [Sodium](https://modrinth.com/mod/sodium), [Lithium](https://modrinth.com/mod/lithium), [FerriteCore](https://modrinth.com/mod/ferrite-core), [ImmediatelyFast](https://modrinth.com/mod/immediatelyfast), [EntityCulling](https://modrinth.com/mod/entityculling), and [Distant Horizons](https://modrinth.com/mod/distanthorizons). Cinematic adds [Iris](https://modrinth.com/mod/iris) and [Complementary Reimagined](https://modrinth.com/shader/complementary-reimagined), initially disabled. Vulkan independently experiments with the game's own backend and fallback, without Iris.
|
||||
|
||||
Distant LOD is simplified terrain. Nearby blocks retain full detail. Initial generation costs CPU/disk/memory and needs warm-up. DH adjusts clouds, chunk fade and transparency to blend its renderer; assess appearance as well as speed. Shaders cost GPU time and have [Mac-specific limits](https://shaders.properties/current/reference/miscellaneous/macos/). Universal CPU/GPU support and exact vanilla equivalence remain validation goals. Simulation distance stays 12 in every profile; normal 20 TPS rules are retained.
|
||||
|
||||
For the first comparison at the same view distance, use `baseline` versus
|
||||
`core-performance`: both select 12 full-detail chunks and no distant LOD or shaders.
|
||||
The latter adds only the pinned core performance mods and leaves every original
|
||||
controller disabled. Then test balanced separately for its additional distant
|
||||
terrain. Matching captured settings does not prove pixel-identical rendering.
|
||||
[Core comparison protocol and selected observations](docs/core-performance.md)
|
||||
include three repeated matched-view software-rendered pairs with 14–21% higher
|
||||
FPS in this workload; M2 performance remains unmeasured.
|
||||
|
||||
[Heap budget experiment](docs/heap-budget.md): three pairs with 3 GiB versus
|
||||
1.5 GiB maximum heap show variable RAM savings and GC/frame-time tradeoffs;
|
||||
playable defaults remain unchanged.
|
||||
|
||||
[Matched graphics API comparison](docs/graphics-api-comparison.md): Vulkan adds
|
||||
about 1.5% average FPS but about 12% CPU in three Linux software-rendered pairs;
|
||||
backend defaults remain unchanged and physical Mac performance is unmeasured.
|
||||
|
||||
The optional `queue-wait` pack yields during pending distant-terrain debounce retries; `adaptive-queue` combines it with the existing controllers. [Queue experiment and measurement limits](docs/debounce-queue.md).
|
||||
|
||||
Version 0.16 adds a separate [terrain position arithmetic experiment](docs/terrain-position.md)
|
||||
for the sampled full-data lookup. It retains the pinned library’s unusual
|
||||
negative-coordinate rounding and invalid-level errors. Balanced keeps it off;
|
||||
whole-game and Mac performance still require measurement.
|
||||
|
||||
Version 0.15 adds an isolated [culling ray scratch experiment](docs/culling-rays.md)
|
||||
that reuses two numeric vectors within each culling instance while retaining
|
||||
upstream arithmetic and decisions. Balanced keeps it off; hardware gains remain
|
||||
unmeasured.
|
||||
|
||||
Version 0.14 adds an isolated [empty culling whitelist experiment](docs/culling-empty.md)
|
||||
that avoids allocating iterators while there are no dynamic handlers. Live
|
||||
registration and nonempty callbacks retain upstream behavior. Balanced keeps it
|
||||
off; whole-game gains still need hardware comparisons. DH profiles also request
|
||||
a scoped [interruptible blending-read wait](docs/terrain-read-wait.md) to let
|
||||
background workers leave a pending metadata read during shutdown.
|
||||
|
||||
Version 0.13 adds an isolated [tick membership experiment](docs/tick-membership.md)
|
||||
that reuses compact storage for DH's private per-tick membership checks. The
|
||||
combined foreground profiles include it too; balanced keeps it off. The applied
|
||||
holder test verifies action semantics and removes repeated entry allocations;
|
||||
whole-game FPS and RAM gains still need hardware measurements.
|
||||
|
||||
Version 0.12 captures effective distant-terrain quality, radius, worker budgets,
|
||||
Iris state and field of view. Start/end snapshots flag changed visual settings;
|
||||
[measurement details](docs/terrain-measurements.md) explain their limits.
|
||||
|
||||
Version 0.11 adds combined foreground-first OpenGL/Vulkan profiles with a 120 FPS
|
||||
adaptive goal, plus explicit collector experiments and GC measurement guidance.
|
||||
See [combined controls](docs/foreground-first.md), [Vulkan on your Mac](docs/vulkan.md)
|
||||
and [collector comparisons](docs/collectors.md). The frame goal is not a rendering cap.
|
||||
|
||||
Version 0.10 adds an isolated [rejected-dispatch retry experiment](docs/dispatch-retry.md).
|
||||
It bounds retry passes when a DH worker rejects queued work, preserving original
|
||||
requeue handling and allowing other executors to progress. Balanced keeps it off.
|
||||
|
||||
Version 0.9 also makes the queue's idle wait wakeable for incoming work, preserving
|
||||
its debounce rules and bounded cache-maintenance cadence. It remains part of the
|
||||
optional queue profiles, with separate wake-latency measurements.
|
||||
|
||||
Version 0.8 fixes a fresh-instance graphics preset that could override the selected
|
||||
render distance. It also records actual GC/header settings and supports an optional
|
||||
[compact object headers experiment](docs/compact-headers.md) for Java 25; packs
|
||||
do not enable that JVM flag automatically.
|
||||
|
||||
## Measure on your hardware
|
||||
|
||||
The optional [adaptive terrain experiment](docs/adaptive-terrain.md) throttles distant
|
||||
terrain background work when frames slow down or heap pressure rises, keeping the
|
||||
balanced profile's view distances and simulation settings. It remains opt-in until
|
||||
real hardware demonstrates a benefit.
|
||||
|
||||
The additional [precise worker pacing experiment](docs/worker-pacing.md) makes short
|
||||
terrain tasks respect their duty-cycle budget and avoids boxed worker timestamps.
|
||||
It changes generation speed and CPU use, so it stays separate from balanced.
|
||||
|
||||
Press **F8** during gameplay to start a 120-second recording, or again to stop early. Rebind in Controls if needed. This also works with restricted chat accounts. Alternatively: `/horizon bench <label> 120`, `/horizon stop`, `/horizon status`.
|
||||
|
||||
Use separate identical world copies and the same camera route, resolution, render/simulation distance, heap, VSync and FPS limit. Warm up, then alternate at least three baseline/optimized runs. Test fresh chunk exploration separately. For everyday play cap FPS or enable VSync to reduce power usage.
|
||||
|
||||
The instance's `horizon-benchmarks/` folder receives JSON/CSV frame and integrated-server tick timings: average FPS, slowest-1%-average FPS, p95/p99 durations, stalls, JVM heap/GC, sampled process RSS when available, GPU/backend/driver and render settings. Frames include presentation/limiting waits and exclude menus/pauses/unfocused time. Tick `meanRate` is execution capacity, **not TPS**; observed tick counts are separate. Version 0.20 [captures sampled tick-manager state](docs/tick-state.md), including restored freeze/step/sprint/rate changes; this does not measure completed world work. Resident process memory is sampled on a shared background thread only during recordings; it includes native resident pages, while heap measures JVM objects. Neither is a complete measurement of unified GPU memory. [Memory measurement details](docs/process-memory.md).
|
||||
|
||||
```sh
|
||||
python3 tools/compare_benchmarks.py baseline-frames.json optimized-frames.json --strict
|
||||
```
|
||||
|
||||
Open `Compare-Benchmarks.html` from the starter ZIP or [the source file](tools/Compare-Benchmarks.html) in your browser to compare without Python. Select each JSON and optionally its matching CSV. It exports summary JSON, flags settings and duration differences, and charts frames by recorded active time or ticks by sample number. It is self-contained and makes no network requests.
|
||||
|
||||
Version 0.18 adds an isolated [terrain quad reuse experiment](docs/quad-reuse.md).
|
||||
It reuses a temporary quad discarded by successful premerge when the existing
|
||||
pool would otherwise allocate. Generated geometry and thread ownership checks
|
||||
pass; allocation results are not whole-game RAM or FPS evidence. It remains off
|
||||
in combined profiles.
|
||||
|
||||
Version 0.17 also records loaded mod versions and the Java vendor, VM and runtime
|
||||
build in client and server reports. Both comparison tools flag software changes
|
||||
as explicit experiment variables; older reports remain usable with an unknown
|
||||
software warning. The viewer can expand the complete loaded mod list.
|
||||
[Live software identity check](benchmarks/software-identity-2026-10-04/live-observations.json)
|
||||
records the metadata and file-count validation; its short capture is not a
|
||||
performance comparison.
|
||||
|
||||
|
||||
The comparison flags captured setting differences, but cannot verify route, world, shader state or cache warm-up. See [validation evidence](docs/playable-validation.md). Software-renderer test FPS is not evidence of a Mac/GPU speedup.
|
||||
|
||||
For CPU/allocation investigation, [capture and stream a private JFR summary](docs/profiling.md).
|
||||
It distinguishes directly observed allocation counters from sampled hot methods;
|
||||
profiling runs remain separate from normal timing comparisons.
|
||||
|
||||
## Build and run
|
||||
|
||||
Requires Python 3.10+ and JDK 25. No Gradle, Rust or native compilation is required for playable packs.
|
||||
|
||||
```sh
|
||||
python3 tools/build_mod.py
|
||||
python3 tools/build_release.py
|
||||
```
|
||||
|
||||
Gitignored `dist/` contains twenty packs, our original instrumentation JAR, client/server starter ZIPs and SHA256SUMS. [mods.lock.json](packs/mods.lock.json) pins mod versions, exact dependency closure, SHA-1/SHA-512 and source/license; [fabric.lock.json](packs/fabric.lock.json) pins loader/library SHA-256. Packs use the [Modrinth format](https://support.modrinth.com/en/articles/8802351-modrinth-modpack-format-mrpack). They embed only original project code; third-party mods, game and assets download from their publishers.
|
||||
|
||||
The server starter uses Lithium/FerriteCore and `/horizonserver bench <label> <seconds>`. [Server instructions](docs/server-quickstart.txt) cover setup and a baseline. Files are prepared before you accept Minecraft's EULA locally. A 50-active-player workload remains to be measured.
|
||||
|
||||
Local smoke testing uses the game's built-in limited demo; normal play uses Prism's licensed account authentication:
|
||||
|
||||
```sh
|
||||
python3 tools/build_pack.py --profile m2-balanced --instance .local/runtime/m2-balanced
|
||||
vscreen run python3 tools/runtime.py demo --instance .local/runtime/m2-balanced
|
||||
```
|
||||
|
||||
The standalone demo launcher applies the pinned official manifest's conditional
|
||||
JVM flags, including macOS `-XstartOnFirstThread` and the Windows heap-dump-path
|
||||
option. Prism applies its own official manifest. Server JVM policy is separate;
|
||||
this launcher correction is not a measured graphics performance improvement.
|
||||
|
||||
## Local game inspection
|
||||
|
||||
Game JARs, decompiled Java, libraries, inventories, worlds and logs live **inside this checkout** under gitignored **`.local/`**. No game code is in Git history or release downloads. Fresh clones regenerate the inspection workspace from official downloads; Microsoft credentials are not needed for this step.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ludovic111/horizon.git
|
||||
cd horizon
|
||||
git config core.hooksPath .githooks
|
||||
python3 tools/lab.py prepare
|
||||
python3 tools/lab.py decompile
|
||||
python3 tools/lab.py status
|
||||
```
|
||||
|
||||
`research-lock.json` pins the official manifest and Vineflower. The inner server JAR is extracted and checked; client/server libraries provide decompiler context. Output: `.local/minecraft/26.3/sources/{client,server}/` and coverage/inventory reports. Current inventories cover 11,383 client classes and 7,762 server classes, producing 7,301/5,037 Java files. See [decompilation status](docs/decompilation-status.md).
|
||||
|
||||
Class-file SourceFile metadata covers inner classes, which often share a source file. Coverage does not prove semantic equivalence or recompilability; original bytecode remains the reference. Completed source directories are not overwritten automatically. Older obfuscated releases require a separate remapping stage.
|
||||
|
||||
## Original Rust research
|
||||
|
||||
The dependency-free `horizon-core` crate starts the Rust simulation foundation:
|
||||
integer block/chunk/section positions, legacy and xoroshiro seeded random streams and positional factories,
|
||||
padded palette-index storage, scheduled block/fluid tick queues, and double-precision
|
||||
bounding-box and voxel-grid collision geometry. Noise samplers, octave stacks and
|
||||
bulk density kernels provide the next world-generation foundation. Compiled
|
||||
density samplers add gradients, shifted and blended terrain noise, distance
|
||||
metrics and arithmetic evaluation.
|
||||
Differential runners compare selected
|
||||
operations with the checksum-pinned vanilla client and server bytecode.
|
||||
[Implemented scope, validation, and remaining rewrite work](docs/rust-parity.md).
|
||||
|
||||
```sh
|
||||
python3 tools/test_rust_parity.py --prepare
|
||||
python3 tools/test_world_parity.py
|
||||
python3 tools/test_tick_parity.py
|
||||
python3 tools/test_geometry_parity.py
|
||||
python3 tools/test_collision_parity.py
|
||||
python3 tools/test_noise_parity.py
|
||||
python3 tools/test_density_parity.py
|
||||
```
|
||||
|
||||
The core corpus checks 1,634,520 output lines per reference JAR, including
|
||||
signed boundaries, RNG rejection/state consumption and raw storage words.
|
||||
Passing it does not establish full gameplay parity or an FPS improvement.
|
||||
The separate [noise suite](docs/noise-generation.md) checks 26.3 Perlin/simplex,
|
||||
derivatives, density-volume accumulation, stack composition, normal-noise
|
||||
normalization and modern/legacy initialization against both reference JARs.
|
||||
The [density suite](docs/density-functions.md) checks compiled scalar and bulk
|
||||
evaluation, including nested samplers, their different arithmetic paths, and
|
||||
selected interval operations used for graph range analysis.
|
||||
|
||||
`horizon-world` adds NBT, four region compression codecs, `.mca`/`.mcc`
|
||||
reading and export, saved palettes, a version-checked block-state registry,
|
||||
and packed resident block sections. The world-data suite checks Rust output
|
||||
with the actual vanilla NBT, region and block-state decoders on both JARs.
|
||||
It covers all 35,723 exported state IDs and a 100,000-edit section trace.
|
||||
[World-data commands and validation limits](docs/world-data.md).
|
||||
|
||||
Scheduled ticks now support vanilla ordering, duplicate suppression, save/reload,
|
||||
chunk eligibility and callback mutations. The tick suite compares 97,776 output
|
||||
lines per reference JAR and checks saved block/fluid lists through vanilla codecs
|
||||
and region files. [Scheduler behavior and remaining integration](docs/scheduled-ticks.md).
|
||||
|
||||
Voxel shapes preserve vanilla collision tolerances, greedy box ordering, ray faces,
|
||||
inside hits, and cube-versus-array cell lookup. A cached collision registry connects
|
||||
imported shapes to resident block sections while checking exact state identities.
|
||||
Movement helpers resolve supplied colliders and step-up candidates, including
|
||||
landing adjustment and the second collider query. Movement-path block visits
|
||||
preserve vanilla ordering, step indices and packed-position deduplication.
|
||||
Uncached states require block behavior; the entity-context overload also needs
|
||||
separate implementation. [Geometry and collision scope](docs/collision-geometry.md).
|
||||
|
||||
```sh
|
||||
cargo test --workspace
|
||||
cargo run --release --bin voxel-bench -- 100 > .local/voxel-benchmark.csv
|
||||
```
|
||||
|
||||
The dependency-free occupancy experiment compares scalar and bitset exposed-face counting for synthetic 16³ sections. It has no real mesh, material/AO/light work, neighboring-section context, JNI bridge or game integration. Speedups are kernel-only, not Minecraft FPS. [Research plan](docs/optimization-plan.md) describes profiling, improved Java baselines, native integration and behavior checks before expanding scope.
|
||||
|
||||
Public branding and new mod downloads use **Horizon**. The existing Fabric mod
|
||||
ID and `dev.horizonlab` namespace remain stable for installed instances and
|
||||
measurement compatibility. The installer backs up both old `horizon-lab-*.jar`
|
||||
and new `horizon-*.jar` versions before installing an update.
|
||||
|
||||
## Validation and ownership
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -v
|
||||
python3 tools/build_mod.py --test-only
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
python3 tools/check_public_tree.py
|
||||
python3 tools/check_public_tree.py --history
|
||||
```
|
||||
|
||||
CI checks offline tools/Rust and compiles the original mod/builds packs on Linux and Apple Silicon macOS. Game dependencies download to ephemeral `.local/`; CI uploads only our original JAR and packs. Compilation is not Mac GPU gameplay testing. Publication checks allow Java only under our original `dev/horizonlab` namespace; review diffs as well.
|
||||
CI runs these checks on GitHub's Linux runners. The vanilla comparisons need the game, which Horizon never downloads, so they run only when started by hand on a self-hosted runner holding the maintainer's own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
|
||||
|
||||
[MIT](LICENSE) covers only our original tracked work. Dependencies retain their licenses. Mojang [removed obfuscation](https://www.minecraft.net/en-us/article/removing-obfuscation-in-java-edition) without changing [Minecraft's EULA](https://www.minecraft.net/en-us/eula). Decompiled game source remains local; published code implements independent tooling and instrumentation.
|
||||
## Licence
|
||||
|
||||
Integrated-server reports record the shared graphics backend at recording start; see [the context and comparison limits](docs/server-graphics-context.md).
|
||||
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
|
||||
|
||||
## Author
|
||||
|
||||
Made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)).
|
||||
@@ -6,6 +6,7 @@ use horizon_assets::{
|
||||
item_model::*,
|
||||
math::Mat4,
|
||||
model::{CuboidModel, Direction},
|
||||
palette::{PalettedPermutations, apply_palette},
|
||||
resolve::{MISSING_MODEL, ModelLibrary},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -23,6 +24,8 @@ fn bits(v: f32) -> u32 {
|
||||
|
||||
struct Sprites {
|
||||
root: PathBuf,
|
||||
/// The items atlas's paletted permutations (armour trim sprites).
|
||||
permutations: Vec<PalettedPermutations>,
|
||||
cache: RefCell<BTreeMap<String, Option<SpriteMask>>>,
|
||||
}
|
||||
impl SpriteLookup for Sprites {
|
||||
@@ -38,27 +41,48 @@ impl SpriteLookup for Sprites {
|
||||
}
|
||||
}
|
||||
impl Sprites {
|
||||
fn load(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
let (namespace, path) = sprite.split_once(':')?;
|
||||
let png = self
|
||||
.root
|
||||
.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(&png).ok()?));
|
||||
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
|
||||
let (namespace, path) = sprite.split_once(':').unwrap_or(("minecraft", sprite));
|
||||
self.root
|
||||
.join(format!("assets/{namespace}/{directory}/{path}.png"))
|
||||
}
|
||||
/// The image as ARGB pixels in reading order, with its size.
|
||||
fn argb(png: &Path) -> Option<(usize, usize, Vec<u32>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(png).ok()?));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size()?];
|
||||
let info = reader.next_frame(&mut bytes).ok()?;
|
||||
let channels = info.color_type.samples();
|
||||
let alpha = |pixel: &[u8]| match info.color_type {
|
||||
png::ColorType::Rgba => pixel[3],
|
||||
png::ColorType::GrayscaleAlpha => pixel[1],
|
||||
_ => 255,
|
||||
};
|
||||
let (iw, ih) = (info.width as usize, info.height as usize);
|
||||
let pixels: Vec<u8> = bytes[..info.buffer_size()]
|
||||
let pixels = bytes[..info.buffer_size()]
|
||||
.chunks_exact(channels)
|
||||
.map(alpha)
|
||||
.map(|p| {
|
||||
let (r, g, b, a) = match info.color_type {
|
||||
png::ColorType::Rgba => (p[0], p[1], p[2], p[3]),
|
||||
png::ColorType::Rgb => (p[0], p[1], p[2], 255),
|
||||
png::ColorType::GrayscaleAlpha => (p[0], p[0], p[0], p[1]),
|
||||
_ => (p[0], p[0], p[0], 255),
|
||||
};
|
||||
u32::from_be_bytes([a, r, g, b])
|
||||
})
|
||||
.collect();
|
||||
Some((info.width as usize, info.height as usize, pixels))
|
||||
}
|
||||
fn load(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
let png = self.png(sprite, "textures");
|
||||
let (iw, ih, argb) = match Self::argb(&png) {
|
||||
Some(image) => image,
|
||||
None => {
|
||||
// PalettedPermutations: the base texture through PaletteMapping.
|
||||
let p = self.permutations.iter().find_map(|s| s.find(sprite))?;
|
||||
let (w, h, mut pixels) = Self::argb(&self.png(p.base, "textures"))?;
|
||||
let (_, _, key) = Self::argb(&self.png(p.palette_key, "textures/palettes"))?;
|
||||
let (_, _, target) = Self::argb(&self.png(p.palette, "textures/palettes"))?;
|
||||
apply_palette(&mut pixels, &key, &target)?;
|
||||
(w, h, pixels)
|
||||
}
|
||||
};
|
||||
let pixels: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
|
||||
let meta = fs::read_to_string(png.with_extension("png.mcmeta")).ok();
|
||||
let animation = meta
|
||||
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
|
||||
@@ -215,6 +239,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let library = ModelLibrary::with_item_generator(cuboids)?;
|
||||
let sprites = Sprites {
|
||||
root: root.clone(),
|
||||
permutations: PalettedPermutations::from_atlas(
|
||||
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
cache: RefCell::default(),
|
||||
};
|
||||
let grass = {
|
||||
|
||||
@@ -342,9 +342,11 @@ fn optional_transformation(m: &Map<String, Value>) -> Result<Option<Mat4>> {
|
||||
get(m, "transformation").map(transformation).transpose()
|
||||
}
|
||||
/// `Mth.floor(float)` as `ARGB.as8BitChannel` uses it.
|
||||
/// `ARGB.as8BitChannel`: `Mth.floor(value * 255.0F)`.
|
||||
fn channel(v: f32) -> i32 {
|
||||
let i = v as i32;
|
||||
if (i as f32) > v { i - 1 } else { i }
|
||||
let scaled = v * 255.0;
|
||||
let i = scaled as i32;
|
||||
if (i as f32) > scaled { i - 1 } else { i }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -207,9 +207,7 @@ impl SpecialModel {
|
||||
}
|
||||
SpecialKind::PlayerHead => "player_head",
|
||||
SpecialKind::ShulkerBox { texture, openness } => {
|
||||
if texture != "minecraft:shulker" {
|
||||
put("texture", json!(texture));
|
||||
}
|
||||
put("texture", json!(texture));
|
||||
if *openness != 0.0 {
|
||||
put("openness", json!(openness));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod item_model;
|
||||
mod json;
|
||||
pub mod math;
|
||||
pub mod model;
|
||||
pub mod palette;
|
||||
pub mod resolve;
|
||||
|
||||
pub use json::DecodeError;
|
||||
@@ -1,31 +1,91 @@
|
||||
//! Column-major `Matrix4f` arithmetic with the operand order of JOML's
|
||||
//! default (non-FMA) code paths, for composing item-model transformations.
|
||||
//! Column-major `Matrix4f` arithmetic as JOML 1.10.9 computes it, for composing
|
||||
//! item-model transformations: each operation takes the path JOML picks from
|
||||
//! the matrix properties (identity, translation, affine...), and `Math.fma` is
|
||||
//! `a * b + c` since Minecraft leaves `joml.useMathFma` off.
|
||||
|
||||
/// A JOML `Matrix4f`: `m[column][row]`.
|
||||
/// `Matrix4fc.PROPERTY_*` bits.
|
||||
const PERSPECTIVE: u8 = 1;
|
||||
const AFFINE: u8 = 2;
|
||||
const IDENTITY: u8 = 4;
|
||||
const TRANSLATION: u8 = 8;
|
||||
const ORTHONORMAL: u8 = 16;
|
||||
|
||||
/// JOML `Math.fma` without `joml.useMathFma`.
|
||||
#[inline]
|
||||
fn fma(a: f32, b: f32, c: f32) -> f32 {
|
||||
a * b + c
|
||||
}
|
||||
/// JOML `Math.absEqualsOne`.
|
||||
fn abs_equals_one(r: f32) -> bool {
|
||||
r.to_bits() & 0x7FFF_FFFF == 0x3F80_0000
|
||||
}
|
||||
|
||||
/// A JOML `Matrix4f`: `m[column][row]`, with its property bits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Mat4(pub [[f32; 4]; 4]);
|
||||
pub struct Mat4(pub [[f32; 4]; 4], pub u8);
|
||||
|
||||
impl Mat4 {
|
||||
pub const IDENTITY: Self = Self([
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]);
|
||||
/// `new Matrix4f()`.
|
||||
pub const IDENTITY: Self = Self(
|
||||
[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
],
|
||||
IDENTITY | AFFINE | TRANSLATION | ORTHONORMAL,
|
||||
);
|
||||
/// `Matrix4f.translation(x, y, z)`.
|
||||
pub fn translation(t: [f32; 3]) -> Self {
|
||||
let mut m = Self::IDENTITY;
|
||||
m.0[3][..3].copy_from_slice(&t);
|
||||
m.1 = AFFINE | TRANSLATION | ORTHONORMAL;
|
||||
m
|
||||
}
|
||||
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order.
|
||||
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order, then
|
||||
/// `determineProperties`.
|
||||
pub fn from_row_major(values: [f32; 16]) -> Self {
|
||||
let mut m = Self([[0.0; 4]; 4]);
|
||||
let mut m = Self([[0.0; 4]; 4], 0);
|
||||
for (i, v) in values.into_iter().enumerate() {
|
||||
m.0[i & 3][i >> 2] = v;
|
||||
}
|
||||
m.1 = m.determine_properties();
|
||||
m
|
||||
}
|
||||
/// `Matrix4f.determineProperties`.
|
||||
fn determine_properties(&self) -> u8 {
|
||||
let m = &self.0;
|
||||
let mut properties = 0;
|
||||
if m[0][3] == 0.0 && m[1][3] == 0.0 {
|
||||
if m[2][3] == 0.0 && m[3][3] == 1.0 {
|
||||
properties |= AFFINE;
|
||||
if m[0] == [1.0, 0.0, 0.0, 0.0]
|
||||
&& m[1] == [0.0, 1.0, 0.0, 0.0]
|
||||
&& m[2] == [0.0, 0.0, 1.0, 0.0]
|
||||
{
|
||||
properties |= TRANSLATION | ORTHONORMAL;
|
||||
if m[3][0] == 0.0 && m[3][1] == 0.0 && m[3][2] == 0.0 {
|
||||
properties |= IDENTITY;
|
||||
}
|
||||
}
|
||||
} else if m[0][1] == 0.0
|
||||
&& m[0][2] == 0.0
|
||||
&& m[1][0] == 0.0
|
||||
&& m[1][2] == 0.0
|
||||
&& m[2][0] == 0.0
|
||||
&& m[2][1] == 0.0
|
||||
&& m[3][0] == 0.0
|
||||
&& m[3][1] == 0.0
|
||||
&& m[3][3] == 0.0
|
||||
{
|
||||
properties |= PERSPECTIVE;
|
||||
}
|
||||
}
|
||||
properties
|
||||
}
|
||||
fn has(&self, property: u8) -> bool {
|
||||
self.1 & property != 0
|
||||
}
|
||||
/// The matrix as `[row][column]` entries, the order `getRowColumn` reads.
|
||||
pub fn rows(&self) -> [[f32; 4]; 4] {
|
||||
std::array::from_fn(|r| std::array::from_fn(|c| self.0[c][r]))
|
||||
@@ -33,37 +93,143 @@ impl Mat4 {
|
||||
/// `this * right` (`Matrix4f.mul`).
|
||||
pub fn mul(&self, right: &Self) -> Self {
|
||||
let (m, r) = (&self.0, &right.0);
|
||||
if self.has(IDENTITY) {
|
||||
return *right;
|
||||
}
|
||||
if right.has(IDENTITY) {
|
||||
return *self;
|
||||
}
|
||||
let mut out = [[0.0; 4]; 4];
|
||||
if self.has(TRANSLATION) && right.has(AFFINE) {
|
||||
// mulTranslationAffine
|
||||
for c in 0..4 {
|
||||
out[c] = [r[c][0], r[c][1], r[c][2], m[c][3]];
|
||||
}
|
||||
for row in 0..3 {
|
||||
out[3][row] = r[3][row] + m[3][row];
|
||||
}
|
||||
return Self(out, AFFINE | right.1 & ORTHONORMAL);
|
||||
}
|
||||
if self.has(AFFINE) && right.has(AFFINE) {
|
||||
// mulAffine
|
||||
for c in 0..3 {
|
||||
for row in 0..3 {
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
|
||||
);
|
||||
}
|
||||
out[c][3] = m[c][3];
|
||||
}
|
||||
for row in 0..3 {
|
||||
out[3][row] = fma(
|
||||
m[0][row],
|
||||
r[3][0],
|
||||
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
|
||||
);
|
||||
}
|
||||
out[3][3] = m[3][3];
|
||||
return Self(out, AFFINE | self.1 & right.1 & ORTHONORMAL);
|
||||
}
|
||||
if self.has(PERSPECTIVE) && right.has(AFFINE) {
|
||||
// mulPerspectiveAffine
|
||||
for c in 0..4 {
|
||||
out[c] = [
|
||||
m[0][0] * r[c][0],
|
||||
m[1][1] * r[c][1],
|
||||
m[2][2] * r[c][2],
|
||||
m[2][3] * r[c][2],
|
||||
];
|
||||
}
|
||||
out[3][2] = m[2][2] * r[3][2] + m[3][2];
|
||||
out[3][3] = m[2][3] * r[3][2];
|
||||
return Self(out, 0);
|
||||
}
|
||||
if right.has(AFFINE) {
|
||||
// mulAffineR
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
|
||||
);
|
||||
}
|
||||
}
|
||||
for row in 0..4 {
|
||||
out[3][row] = fma(
|
||||
m[0][row],
|
||||
r[3][0],
|
||||
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
|
||||
);
|
||||
}
|
||||
return Self(
|
||||
out,
|
||||
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | ORTHONORMAL),
|
||||
);
|
||||
}
|
||||
// mul0
|
||||
for c in 0..4 {
|
||||
for row in 0..4 {
|
||||
out[c][row] = m[0][row] * r[c][0]
|
||||
+ m[1][row] * r[c][1]
|
||||
+ m[2][row] * r[c][2]
|
||||
+ m[3][row] * r[c][3];
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(
|
||||
m[1][row],
|
||||
r[c][1],
|
||||
fma(m[2][row], r[c][2], m[3][row] * r[c][3]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self(out)
|
||||
Self(out, 0)
|
||||
}
|
||||
/// `Matrix4f.translate(x, y, z)` (`translateGeneric`).
|
||||
/// `Matrix4f.translate(x, y, z)`.
|
||||
pub fn translate(&self, t: [f32; 3]) -> Self {
|
||||
if self.has(IDENTITY) {
|
||||
return Self::translation(t);
|
||||
}
|
||||
// translateGeneric
|
||||
let mut out = *self;
|
||||
for row in 0..4 {
|
||||
out.0[3][row] = self.0[0][row] * t[0]
|
||||
+ (self.0[1][row] * t[1] + (self.0[2][row] * t[2] + self.0[3][row]));
|
||||
out.0[3][row] = fma(
|
||||
self.0[0][row],
|
||||
t[0],
|
||||
fma(
|
||||
self.0[1][row],
|
||||
t[1],
|
||||
fma(self.0[2][row], t[2], self.0[3][row]),
|
||||
),
|
||||
);
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY);
|
||||
out
|
||||
}
|
||||
/// `Matrix4f.scale(x, y, z)`: the first three columns are scaled.
|
||||
/// `Matrix4f.scale(x, y, z)`.
|
||||
pub fn scale(&self, s: [f32; 3]) -> Self {
|
||||
let one = s.iter().all(|&v| abs_equals_one(v));
|
||||
if self.has(IDENTITY) {
|
||||
// scaling
|
||||
let mut out = Self::IDENTITY;
|
||||
for (c, v) in s.into_iter().enumerate() {
|
||||
out.0[c][c] = v;
|
||||
}
|
||||
out.1 = AFFINE | if one { ORTHONORMAL } else { 0 };
|
||||
return out;
|
||||
}
|
||||
// scaleGeneric: the first three columns are scaled.
|
||||
let mut out = *self;
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = self.0[c][row] * s[c];
|
||||
}
|
||||
}
|
||||
out.1 =
|
||||
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | if one { 0 } else { ORTHONORMAL });
|
||||
out
|
||||
}
|
||||
/// `Matrix4f.rotate(Quaternionfc)` (`rotateGeneric`).
|
||||
/// `Matrix4f.rotate(Quaternionfc)`.
|
||||
pub fn rotate(&self, q: [f32; 4]) -> Self {
|
||||
let [x, y, z, w] = q;
|
||||
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
|
||||
@@ -71,14 +237,43 @@ impl Mat4 {
|
||||
let (dzw, dxy, dxz, dyw, dyz, dxw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
|
||||
let r = [
|
||||
[w2 + x2 - z2 - y2, dxy + dzw, dxz - dyw],
|
||||
[dxy - dzw, y2 - z2 + w2 - x2, dyz + dxw],
|
||||
[-dzw + dxy, y2 - z2 + w2 - x2, dyz + dxw],
|
||||
[dyw + dxz, dyz - dxw, z2 - y2 - x2 + w2],
|
||||
];
|
||||
let m = &self.0;
|
||||
let mut out = *self;
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
|
||||
if self.has(IDENTITY) {
|
||||
// rotation
|
||||
for c in 0..3 {
|
||||
out.0[c][..3].copy_from_slice(&r[c]);
|
||||
}
|
||||
out.1 = AFFINE | ORTHONORMAL;
|
||||
return out;
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION);
|
||||
if self.has(TRANSLATION) {
|
||||
// rotateTranslation
|
||||
for c in 0..3 {
|
||||
out.0[c] = [r[c][0], r[c][1], r[c][2], 0.0];
|
||||
}
|
||||
} else if self.has(AFFINE) {
|
||||
// rotateAffine
|
||||
for c in 0..3 {
|
||||
for row in 0..3 {
|
||||
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
|
||||
}
|
||||
out.0[c][3] = 0.0;
|
||||
}
|
||||
} else {
|
||||
// rotateGeneric
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -94,28 +289,50 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
|
||||
let inv = 1.0 / (x * x + (y * y + (z * z + w * w))).sqrt();
|
||||
[x * inv, y * inv, z * inv, w * inv]
|
||||
}
|
||||
/// JOML `Math.sin(float)` without fast math.
|
||||
fn joml_sin(rad: f32) -> f32 {
|
||||
f64::from(rad).sin() as f32
|
||||
}
|
||||
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
|
||||
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
|
||||
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let a = angle + std::f32::consts::FRAC_PI_2;
|
||||
let mut b = a - (a / tau) as i32 as f32 * tau;
|
||||
if b < 0.0 {
|
||||
b += tau;
|
||||
}
|
||||
if b >= std::f32::consts::PI { -cos } else { cos }
|
||||
}
|
||||
/// `Quaternionf.rotationXYZ` from angles in radians.
|
||||
pub fn quaternion_xyz(x: f32, y: f32, z: f32) -> [f32; 4] {
|
||||
let half = |a: f32| -> (f32, f32) {
|
||||
let h = a * 0.5;
|
||||
((f64::from(h).sin()) as f32, (f64::from(h).cos()) as f32)
|
||||
let s = joml_sin(h);
|
||||
(s, cos_from_sin(s, h))
|
||||
};
|
||||
let ((sx, cx), (sy, cy), (sz, cz)) = (half(x), half(y), half(z));
|
||||
let (cx_cy, sx_cy, cx_sy, sx_sy) = (cx * cy, sx * cy, cx * sy, sx * sy);
|
||||
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
|
||||
[
|
||||
sx_cy * cz + cx_sy * sz,
|
||||
cx_sy * cz - sx_cy * sz,
|
||||
cx_cy * sz + sx_sy * cz,
|
||||
cx_cy * cz - sx_sy * sz,
|
||||
sx * cycz + cx * sysz,
|
||||
cx * sycz - sx * cysz,
|
||||
cx * cysz + sx * sycz,
|
||||
cx * cycz - sx * sysz,
|
||||
]
|
||||
}
|
||||
/// `new Quaternionf(new AxisAngle4f(angle, axis))`.
|
||||
/// `new Quaternionf(new AxisAngle4f(angle, axis))`: the angle is wrapped into
|
||||
/// [0, 2π) and the axis is used as given.
|
||||
pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
|
||||
let half = angle * 0.5;
|
||||
let (sin, cos) = (half.sin(), half.cos());
|
||||
let norm = 1.0 / (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
|
||||
let s = sin * norm;
|
||||
[axis[0] * s, axis[1] * s, axis[2] * s, cos]
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let angle = if angle < 0.0 {
|
||||
tau + angle % tau
|
||||
} else {
|
||||
angle
|
||||
} % tau;
|
||||
let sin = joml_sin(angle * 0.5);
|
||||
let cos = cos_from_sin(sin, angle * 0.5);
|
||||
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -137,4 +354,9 @@ mod tests {
|
||||
let m = Mat4::IDENTITY.rotate(q);
|
||||
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn half_turn_has_exact_zeros_like_joml() {
|
||||
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
|
||||
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! `PalettedPermutations` atlas sources: sprites such as
|
||||
//! `trims/items/boots_trim_quartz` are the base texture with its colours
|
||||
//! swapped through a palette pair (`PaletteMapping`), not files of their own.
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One `minecraft:paletted_permutations` source of an atlas definition.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PalettedPermutations {
|
||||
/// Base sprite names (`minecraft:trims/items/boots_trim`).
|
||||
pub textures: Vec<String>,
|
||||
/// The key palette (`minecraft:trim_base`).
|
||||
pub palette_key: String,
|
||||
/// Suffix to target palette (`quartz` → `minecraft:trim/quartz`).
|
||||
pub permutations: BTreeMap<String, String>,
|
||||
pub separator: String,
|
||||
}
|
||||
|
||||
/// A permuted sprite: its base texture and the palette pair to apply.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Permutation<'a> {
|
||||
pub base: &'a str,
|
||||
pub palette_key: &'a str,
|
||||
pub palette: &'a str,
|
||||
}
|
||||
|
||||
fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl PalettedPermutations {
|
||||
/// The `paletted_permutations` sources of an atlas JSON (`atlases/items.json`).
|
||||
pub fn from_atlas(atlas: &str) -> Vec<Self> {
|
||||
let Ok(json) = serde_json::from_str::<Value>(atlas) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let sources = json
|
||||
.get("sources")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
sources
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(qualified)
|
||||
.as_deref()
|
||||
== Some("minecraft:paletted_permutations")
|
||||
})
|
||||
.filter_map(|s| {
|
||||
Some(Self {
|
||||
textures: s
|
||||
.get("textures")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(qualified)
|
||||
.collect(),
|
||||
palette_key: qualified(s.get("palette_key")?.as_str()?),
|
||||
permutations: s
|
||||
.get("permutations")?
|
||||
.as_object()?
|
||||
.iter()
|
||||
.filter_map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
|
||||
.collect(),
|
||||
separator: s
|
||||
.get("separator")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("_")
|
||||
.to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The permutation that produces `sprite` (`withSuffix(separator + key)`).
|
||||
pub fn find(&self, sprite: &str) -> Option<Permutation<'_>> {
|
||||
let sprite = qualified(sprite);
|
||||
self.textures.iter().find_map(|base| {
|
||||
let suffix = sprite
|
||||
.strip_prefix(base.as_str())?
|
||||
.strip_prefix(self.separator.as_str())?;
|
||||
Some(Permutation {
|
||||
base,
|
||||
palette_key: &self.palette_key,
|
||||
palette: self.permutations.get(suffix)?,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `PaletteMapping.create(key, target).apply` over ARGB pixels; the palettes
|
||||
/// are the pixels of their images in reading order (`Palette.from`).
|
||||
pub fn apply_palette(pixels: &mut [u32], key: &[u32], target: &[u32]) -> Option<()> {
|
||||
if key.len() != target.len() {
|
||||
return None;
|
||||
}
|
||||
let mut map = BTreeMap::new();
|
||||
for (&k, &t) in key.iter().zip(target) {
|
||||
if k >> 24 != 0 {
|
||||
map.insert(k | 0xFF00_0000, t);
|
||||
}
|
||||
}
|
||||
for pixel in pixels {
|
||||
let alpha = *pixel >> 24;
|
||||
if alpha == 0 {
|
||||
continue;
|
||||
}
|
||||
let rgb = *pixel | 0xFF00_0000;
|
||||
let target = map.get(&rgb).copied().unwrap_or(rgb);
|
||||
*pixel = (alpha * (target >> 24) / 255) << 24 | target & 0x00FF_FFFF;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn finds_trim_permutations_and_maps_colours() {
|
||||
let atlas = r#"{"sources":[{"type":"minecraft:paletted_permutations","palette_key":"minecraft:trim_base",
|
||||
"permutations":{"quartz":"minecraft:trim/quartz"},"textures":["minecraft:trims/items/boots_trim"]}]}"#;
|
||||
let sources = PalettedPermutations::from_atlas(atlas);
|
||||
let p = sources[0]
|
||||
.find("minecraft:trims/items/boots_trim_quartz")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(p.base, p.palette),
|
||||
("minecraft:trims/items/boots_trim", "minecraft:trim/quartz")
|
||||
);
|
||||
assert!(
|
||||
sources[0]
|
||||
.find("minecraft:trims/items/boots_trim_gold")
|
||||
.is_none()
|
||||
);
|
||||
let mut pixels = [0xFF11_2233, 0x8011_2233, 0x0011_2233, 0xFF44_5566];
|
||||
apply_palette(&mut pixels, &[0xFF11_2233], &[0xFFAA_BBCC]).unwrap();
|
||||
assert_eq!(pixels, [0xFFAA_BBCC, 0x80AA_BBCC, 0x0011_2233, 0xFF44_5566]);
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,9 @@ cpal = "0.16"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
miniz_oxide = "0.8"
|
||||
|
||||
[features]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Replays `tools/test_ambient_sound_parity.py` scripts through Horizon's
|
||||
//! ambient sound handlers over its `SoundEngine`:
|
||||
//! `ambient_fixture ROOT INPUT OUTPUT`.
|
||||
#[path = "audio_common/mod.rs"]
|
||||
mod common;
|
||||
use common::{Fake, d, f, flush};
|
||||
use horizon_client::ambience::{Ambience, AmbientContext, AmbientSounds, Mood, bubble_column_in};
|
||||
use horizon_client::sound_engine::{Bound, SoundEngine, TickContext, Ticking};
|
||||
use horizon_client::sound_events::{SoundRegistry, normalize};
|
||||
use horizon_core::random::RandomSource;
|
||||
use std::io::Write;
|
||||
|
||||
/// The oracle's scripted brightness.
|
||||
fn light(mode: i32, x: i32, y: i32, z: i32) -> (u8, u8) {
|
||||
let h = x.wrapping_mul(73856093) ^ y.wrapping_mul(19349663) ^ z.wrapping_mul(83492791);
|
||||
let u = h as u32;
|
||||
match mode {
|
||||
0 => (15, 0),
|
||||
1 => (0, 0),
|
||||
2 => (0, ((u >> 4) & 15) as u8),
|
||||
_ => (
|
||||
if h & 3 == 0 { ((u >> 8) & 15) as u8 } else { 0 },
|
||||
((u >> 12) & 15) as u8,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn sounds(a: &[&str]) -> AmbientSounds {
|
||||
AmbientSounds {
|
||||
r#loop: (a[1] != "-").then(|| normalize(a[1])),
|
||||
mood: (a[2] != "-").then(|| {
|
||||
let m: Vec<&str> = a[2].split(',').collect();
|
||||
Mood {
|
||||
sound: normalize(m[0]),
|
||||
tick_delay: m[1].parse().expect("delay"),
|
||||
block_search_extent: m[2].parse().expect("extent"),
|
||||
offset: d(m[3]),
|
||||
}
|
||||
}),
|
||||
additions: a[3..]
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let p: Vec<&str> = s.split(',').collect();
|
||||
(normalize(p[0]), d(p[1]))
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
|
||||
engine.listening = true;
|
||||
let mut backend = Fake::default();
|
||||
let mut ambience = Ambience::new(0);
|
||||
let mut current = AmbientSounds::default();
|
||||
let (mut position, mut eye, mut eye_in_water, mut spectator, mut removed, mut mode) =
|
||||
([0.0f64; 3], 0.0f32, false, false, false, 0);
|
||||
let mut height = 1.8f32;
|
||||
let mut columns: Vec<([i32; 3], bool)> = Vec::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
writeln!(out, "{line}").expect("write");
|
||||
let a: Vec<&str> = line.split(' ').collect();
|
||||
match a[0] {
|
||||
"start" => {
|
||||
engine.stop_all(&mut backend);
|
||||
ambience.clear(&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
});
|
||||
ambience = Ambience::new(a[1].parse().expect("seed"));
|
||||
current = AmbientSounds::default();
|
||||
(position, eye, eye_in_water, spectator, removed, mode) =
|
||||
([0.0; 3], 0.0, false, false, false, 0);
|
||||
height = 1.8;
|
||||
columns.clear();
|
||||
}
|
||||
"sounds" => current = sounds(&a),
|
||||
"player" => {
|
||||
position = [d(a[1]), d(a[2]), d(a[3])];
|
||||
eye = f(a[4]);
|
||||
eye_in_water = a[5] == "1";
|
||||
spectator = a[6] == "1";
|
||||
removed = a[7] == "1";
|
||||
mode = a[8].parse().expect("mode");
|
||||
height = f(a[9]);
|
||||
columns = a[10..]
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let p: Vec<&str> = c.split(',').collect();
|
||||
let n = |i: usize| p[i].parse::<i32>().expect("column");
|
||||
([n(0), n(1), n(2)], p[3] == "1")
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
"volume" => {
|
||||
let source: usize = a[1].parse().expect("source");
|
||||
engine.source_volumes[source] = f(a[2]);
|
||||
engine.refresh_category_volume(source, &mut backend);
|
||||
}
|
||||
"tick" => {
|
||||
let light = |b: [i32; 3]| light(mode, b[0], b[1], b[2]);
|
||||
let ctx = AmbientContext {
|
||||
sounds: ¤t,
|
||||
position,
|
||||
eye_y: position[1] + f64::from(eye),
|
||||
underwater: eye_in_water,
|
||||
spectator,
|
||||
light: &light,
|
||||
bubble_column: bubble_column_in(
|
||||
[
|
||||
position[0] - f64::from(0.3f32),
|
||||
position[1],
|
||||
position[2] - f64::from(0.3f32),
|
||||
position[0] + f64::from(0.3f32),
|
||||
position[1] + f64::from(height),
|
||||
position[2] + f64::from(0.3f32),
|
||||
],
|
||||
|b| columns.iter().find(|c| c.0 == b).map(|c| c.1),
|
||||
),
|
||||
};
|
||||
ambience.tick(
|
||||
&ctx,
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
let tick = TickContext {
|
||||
player_underwater: ambience.was_underwater(),
|
||||
player_removed: removed,
|
||||
};
|
||||
engine.tick(false, &tick, &mut backend);
|
||||
}
|
||||
_ => panic!("{line}"),
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" rand={} mood={:08x} underwater={} delay={}",
|
||||
ambience.random.clone().next_long(),
|
||||
ambience.moodiness.to_bits(),
|
||||
ambience.was_underwater(),
|
||||
ambience.underwater_delay()
|
||||
)
|
||||
.expect("write");
|
||||
for (event, id) in ambience.loops() {
|
||||
let i = engine.instance(*id).expect("loop");
|
||||
let Ticking::BiomeLoop { fade, direction } = i.ticking else {
|
||||
panic!("loop kind");
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
" loop {event} fade={fade} direction={direction} stopped={} active={}",
|
||||
i.stopped,
|
||||
engine.is_active(*id)
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
let mut playing: Vec<String> = engine
|
||||
.active_instances()
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let i = engine.instance(id).expect("instance");
|
||||
format!(
|
||||
" playing {} volume={:08x} looping={} relative={} at={:016x},{:016x},{:016x} stopped={}",
|
||||
i.identifier,
|
||||
i.volume.to_bits(),
|
||||
i.looping,
|
||||
i.relative,
|
||||
i.position[0].to_bits(),
|
||||
i.position[1].to_bits(),
|
||||
i.position[2].to_bits(),
|
||||
i.tickable() && i.stopped
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
playing.sort();
|
||||
for p in playing {
|
||||
writeln!(out, "{p}").expect("write");
|
||||
}
|
||||
flush(&mut engine, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Audio environment attributes from Horizon's `MusicTables` and
|
||||
//! `AmbientTables` for `tools/test_audio_attributes_parity.py`:
|
||||
//! `audio_attributes_fixture ROOT INPUT OUTPUT`.
|
||||
use horizon_client::ambience::AmbientTables;
|
||||
use horizon_client::music::{Music, MusicTables};
|
||||
use horizon_client::sound_events::normalize;
|
||||
use std::io::Write;
|
||||
|
||||
fn music(m: Option<&Music>) -> String {
|
||||
m.map_or("-".to_owned(), |m| {
|
||||
format!(
|
||||
"{},{},{},{}",
|
||||
normalize(&m.sound),
|
||||
m.min_delay,
|
||||
m.max_delay,
|
||||
m.replace_current_music
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = std::path::Path::new(&args[1]);
|
||||
let tables = MusicTables::load(root);
|
||||
let ambient = AmbientTables::load(root);
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for (name, m) in [
|
||||
("MENU", Music::menu()),
|
||||
("CREDITS", Music::credits()),
|
||||
("END_BOSS", Music::end_boss()),
|
||||
("CREATIVE", Music::creative()),
|
||||
("END", Music::end()),
|
||||
("UNDER_WATER", Music::under_water()),
|
||||
("GAME", Music::game()),
|
||||
] {
|
||||
writeln!(out, "{name} {}", music(Some(&m))).expect("write");
|
||||
}
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let (dimension, biome) = (f[0], f[1]);
|
||||
let bg = tables.background(dimension, biome);
|
||||
let mut s = format!(
|
||||
"{line} default={} creative={} underwater={}",
|
||||
music(bg.default.as_ref()),
|
||||
music(bg.creative.as_ref()),
|
||||
music(bg.underwater.as_ref())
|
||||
);
|
||||
for creative in [false, true] {
|
||||
for underwater in [false, true] {
|
||||
s.push_str(&format!(
|
||||
" select={}",
|
||||
music(bg.select(creative, underwater))
|
||||
));
|
||||
}
|
||||
}
|
||||
s.push_str(&format!(
|
||||
" volume={:08x}",
|
||||
tables.volume(dimension, biome).to_bits()
|
||||
));
|
||||
let a = ambient.get(dimension, biome);
|
||||
s.push_str(&format!(
|
||||
" loop={}",
|
||||
a.r#loop.as_deref().map_or("-".to_owned(), normalize)
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" mood={}",
|
||||
a.mood.as_ref().map_or("-".to_owned(), |m| format!(
|
||||
"{},{},{},{:016x}",
|
||||
normalize(&m.sound),
|
||||
m.tick_delay,
|
||||
m.block_search_extent,
|
||||
m.offset.to_bits()
|
||||
))
|
||||
));
|
||||
for (sound, chance) in &a.additions {
|
||||
s.push_str(&format!(
|
||||
" addition={},{:016x}",
|
||||
normalize(sound),
|
||||
chance.to_bits()
|
||||
));
|
||||
}
|
||||
writeln!(out, "{s}").expect("write");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Shared by the audio fixtures: a backend recording what an OpenAL source
|
||||
//! would hold, and the per-step dump the Java oracles print.
|
||||
#![allow(dead_code)]
|
||||
use horizon_client::sound_engine::{Backend, ChannelId, InstanceId, PlayResult, SoundEngine};
|
||||
use horizon_client::sound_events::Sound;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
const INITIAL: u32 = 0x1011;
|
||||
const PLAYING: u32 = 0x1012;
|
||||
const PAUSED: u32 = 0x1013;
|
||||
const STOPPED: u32 = 0x1014;
|
||||
|
||||
/// An OpenAL source's state as `alGetSource*` reports it.
|
||||
#[derive(Clone)]
|
||||
pub struct Source {
|
||||
pub state: u32,
|
||||
pub gain: f32,
|
||||
pub pitch: f32,
|
||||
pub position: [f32; 3],
|
||||
pub relative: bool,
|
||||
pub looping: bool,
|
||||
pub model: u32,
|
||||
pub max: f32,
|
||||
pub rolloff: f32,
|
||||
pub reference: f32,
|
||||
}
|
||||
impl Default for Source {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: INITIAL,
|
||||
gain: 1.0,
|
||||
pitch: 1.0,
|
||||
position: [0.0; 3],
|
||||
relative: false,
|
||||
looping: false,
|
||||
model: 0xD002,
|
||||
max: f32::MAX,
|
||||
rolloff: 1.0,
|
||||
reference: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Buffers and streams are ready at once (silence in the oracle); stopping an
|
||||
/// initial source leaves it initial, as in OpenAL Soft.
|
||||
#[derive(Default)]
|
||||
pub struct Fake {
|
||||
pub sources: HashMap<ChannelId, Source>,
|
||||
next: ChannelId,
|
||||
}
|
||||
impl Backend for Fake {
|
||||
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
|
||||
self.next += 1;
|
||||
self.sources.insert(self.next, Source::default());
|
||||
Some(self.next)
|
||||
}
|
||||
fn destroy(&mut self, channel: ChannelId) {
|
||||
self.sources.remove(&channel);
|
||||
}
|
||||
fn set_pitch(&mut self, c: ChannelId, pitch: f32) {
|
||||
self.sources.get_mut(&c).expect("source").pitch = pitch;
|
||||
}
|
||||
fn set_volume(&mut self, c: ChannelId, volume: f32) {
|
||||
self.sources.get_mut(&c).expect("source").gain = volume;
|
||||
}
|
||||
fn set_attenuation(&mut self, c: ChannelId, linear: Option<f32>) {
|
||||
let s = self.sources.get_mut(&c).expect("source");
|
||||
match linear {
|
||||
Some(max) => {
|
||||
s.model = 0xD003;
|
||||
s.max = max;
|
||||
s.rolloff = 1.0;
|
||||
s.reference = 0.0;
|
||||
}
|
||||
None => s.model = 0,
|
||||
}
|
||||
}
|
||||
fn set_looping(&mut self, c: ChannelId, looping: bool) {
|
||||
self.sources.get_mut(&c).expect("source").looping = looping;
|
||||
}
|
||||
fn set_position(&mut self, c: ChannelId, p: [f64; 3]) {
|
||||
self.sources.get_mut(&c).expect("source").position =
|
||||
[p[0] as f32, p[1] as f32, p[2] as f32];
|
||||
}
|
||||
fn set_relative(&mut self, c: ChannelId, relative: bool) {
|
||||
self.sources.get_mut(&c).expect("source").relative = relative;
|
||||
}
|
||||
fn start(&mut self, c: ChannelId, _sound: &Sound, _looping: bool) {
|
||||
self.sources.get_mut(&c).expect("source").state = PLAYING;
|
||||
}
|
||||
fn stop(&mut self, c: ChannelId) {
|
||||
if let Some(s) = self.sources.get_mut(&c)
|
||||
&& s.state != INITIAL
|
||||
{
|
||||
s.state = STOPPED;
|
||||
}
|
||||
}
|
||||
fn pause(&mut self, c: ChannelId) {
|
||||
let s = self.sources.get_mut(&c).expect("source");
|
||||
if s.state == PLAYING {
|
||||
s.state = PAUSED;
|
||||
}
|
||||
}
|
||||
fn unpause(&mut self, c: ChannelId) {
|
||||
let s = self.sources.get_mut(&c).expect("source");
|
||||
if s.state == PAUSED {
|
||||
s.state = PLAYING;
|
||||
}
|
||||
}
|
||||
fn stopped(&mut self, c: ChannelId) -> bool {
|
||||
self.sources.get(&c).is_none_or(|s| s.state == STOPPED)
|
||||
}
|
||||
}
|
||||
pub fn bits(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
pub fn f(hex: &str) -> f32 {
|
||||
f32::from_bits(u32::from_str_radix(hex, 16).expect("float"))
|
||||
}
|
||||
pub fn d(hex: &str) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(hex, 16).expect("double"))
|
||||
}
|
||||
/// The shared per-step dump (instances, notifications, pool counts).
|
||||
pub fn describe(engine: &SoundEngine, backend: &Fake, name: &str, id: InstanceId) -> String {
|
||||
let mut s = format!(" {name} active={}", engine.is_active(id));
|
||||
match engine.channel(id).and_then(|c| backend.sources.get(&c)) {
|
||||
None => s.push_str(" channel=-"),
|
||||
Some(src) => s.push_str(&format!(
|
||||
" state={:x} gain={} pitch={} pos={},{},{} rel={} loop={} model={:x} max={} rolloff={} ref={}",
|
||||
src.state,
|
||||
bits(src.gain),
|
||||
bits(src.pitch),
|
||||
bits(src.position[0]),
|
||||
bits(src.position[1]),
|
||||
bits(src.position[2]),
|
||||
u8::from(src.relative),
|
||||
u8::from(src.looping),
|
||||
src.model,
|
||||
bits(src.max),
|
||||
bits(src.rolloff),
|
||||
bits(src.reference)
|
||||
)),
|
||||
}
|
||||
s
|
||||
}
|
||||
pub fn flush(engine: &mut SoundEngine, out: &mut impl Write) {
|
||||
for n in std::mem::take(&mut engine.notifications) {
|
||||
writeln!(
|
||||
out,
|
||||
"notify {} {} {} {} {:016x} {:016x} {:016x}",
|
||||
n.identifier,
|
||||
n.subtitle.as_deref().unwrap_or("-"),
|
||||
bits(n.range),
|
||||
n.looping,
|
||||
n.position[0].to_bits(),
|
||||
n.position[1].to_bits(),
|
||||
n.position[2].to_bits()
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
let [used, streaming] = engine.used_channels();
|
||||
writeln!(
|
||||
out,
|
||||
" Sounds: {used}/{} + {streaming}/{}",
|
||||
engine.limits[0], engine.limits[1]
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
|
||||
pub fn result_name(r: PlayResult) -> &'static str {
|
||||
match r {
|
||||
PlayResult::Started => "STARTED",
|
||||
PlayResult::StartedSilently => "STARTED_SILENTLY",
|
||||
PlayResult::NotStarted => "NOT_STARTED",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_auto_jump_parity.py's cases through the local player's
|
||||
//! `MotionWorld::update_auto_jump` over the client's resident test scene
|
||||
//! (actual resources, collision registry and block-collision scan).
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
collisions,
|
||||
motion::{MotionResources, MotionWorld, PlayerMotion},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn fl(v: &Value) -> f32 {
|
||||
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn db(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let [root, input, output, scene_json] = &args[..] else {
|
||||
return Err("usage: auto_jump_probe RESOURCES INPUT OUTPUT SCENE_JSON".into());
|
||||
};
|
||||
let root = PathBuf::from(root);
|
||||
let assets = Assets::load(&root)?;
|
||||
let mut scene = World::test_scene(&assets, 1)?;
|
||||
let binding = collisions::load(&root, assets.registry)?;
|
||||
let resources = MotionResources::load(&root, &assets)?;
|
||||
fs::write(
|
||||
scene_json,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"radius": 1, "resident_chunks": scene.chunks.len(), "model_gallery": assets.model_gallery,
|
||||
}))?,
|
||||
)?;
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for (n, line) in BufReader::new(fs::File::open(input)?).lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let mut restore = Vec::new();
|
||||
for b in c["blocks"].as_array().unwrap() {
|
||||
let (x, y, z) = (
|
||||
b[0].as_i64().unwrap() as i32,
|
||||
b[1].as_i64().unwrap() as i32,
|
||||
b[2].as_i64().unwrap() as i32,
|
||||
);
|
||||
let mut state = assets
|
||||
.registry
|
||||
.default_state(b[3].as_str().unwrap())
|
||||
.ok_or("missing fixture block")?;
|
||||
if let Some(properties) = b[4].as_object()
|
||||
&& !properties.is_empty()
|
||||
{
|
||||
let mut definition = assets.registry.state(state).unwrap().clone();
|
||||
for (name, value) in properties {
|
||||
definition
|
||||
.properties
|
||||
.insert(name.clone(), value.as_str().unwrap().into());
|
||||
}
|
||||
state = assets
|
||||
.registry
|
||||
.id(&definition)
|
||||
.ok_or("missing fixture state")?;
|
||||
}
|
||||
restore.push((x, y, z, scene.block(x, y, z)));
|
||||
scene.set(x, y, z, state)?;
|
||||
}
|
||||
let p = &c["player"];
|
||||
let position = Vec3::new(db(&p["pos"][0]), db(&p["pos"][1]), db(&p["pos"][2]));
|
||||
let mut player = PlayerMotion::new(&resources, position)?;
|
||||
player.ground.on_ground = p["ground"].as_bool().unwrap();
|
||||
player.shift_key_down = p["shift"].as_bool().unwrap();
|
||||
player.yaw = fl(&p["yr"]);
|
||||
player.pitch = fl(&p["xr"]);
|
||||
let key = player
|
||||
.attributes
|
||||
.key("minecraft:movement_speed")
|
||||
.ok_or("speed")?;
|
||||
let handle = player
|
||||
.attributes
|
||||
.get_instance(Some(key))
|
||||
.map_err(|e| format!("{e:?}"))?
|
||||
.ok_or("speed instance")?;
|
||||
let speed = f64::from(fl(&p["speed"]));
|
||||
player
|
||||
.attributes
|
||||
.edit(&handle, |a, o| a.set_base_value(speed, o))
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
player.move_vector = [fl(&p["move"][0]), fl(&p["move"][1])];
|
||||
player.dimensions.width = fl(&p["width"]);
|
||||
player.dimensions.height = fl(&p["height"]);
|
||||
player.status.jump_boost = p["boost"].as_i64().map(|a| a as i32);
|
||||
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
|
||||
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
|
||||
MotionWorld {
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.update_auto_jump(fl(&c["xa"]), fl(&c["za"]))?;
|
||||
writeln!(out, "jump {}", player.auto_jump_time)?;
|
||||
for (x, y, z, state) in restore.into_iter().rev() {
|
||||
scene.set(x, y, z, state)?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
//! Headless transport for the block-entity animation parity gate: replays each
|
||||
//! scripted case (block, neighbourhood, entities, block events, game times)
|
||||
//! through `horizon_client::block_entity_anim` and prints the same lines as
|
||||
//! `VanillaBlockEntityAnimOracle`: the block entity's fields, the level calls
|
||||
//! its tick made and the render state the renderer derives from `BlockAnim`.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
block_entity_anim::{
|
||||
Bell, BellEntity, BlockEntityAnims, ChestLid, Conduit, ConduitTarget, DIRECTIONS,
|
||||
EndGateway, PotWobble, Shulker, ShulkerStatus, Skull, WobbleStyle, chest_lid_x_rot,
|
||||
direction_index,
|
||||
},
|
||||
block_entity_render::{BlockEntityRenderer, gateway_beam},
|
||||
blocks::{BlockFacts, Fluid},
|
||||
items::Items,
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn f(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:016x}", v.to_bits())
|
||||
}
|
||||
fn bx(b: [f64; 6]) -> String {
|
||||
b.map(d).join(" ")
|
||||
}
|
||||
|
||||
fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
let wanted: Vec<(&str, &str)> = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.collect();
|
||||
let default = registry.state(registry.default_state(name)?)?.clone();
|
||||
(0..registry.len() as u32).find(|&id| {
|
||||
registry.state(BlockStateId(id)).is_some_and(|s| {
|
||||
s.name == name
|
||||
&& s.properties.iter().all(|(k, v)| {
|
||||
wanted
|
||||
.iter()
|
||||
.find(|(wk, _)| wk == k)
|
||||
.map_or(default.properties.get(k) == Some(v), |(_, wv)| wv == v)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn prop<'a>(spec: &'a str, key: &str) -> Option<&'a str> {
|
||||
let props = spec.split_once('[')?.1.trim_end_matches(']');
|
||||
props
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| v)
|
||||
}
|
||||
|
||||
fn offset(key: &str) -> [i32; 3] {
|
||||
let mut it = key.split(',').map(|v| v.parse::<i32>().unwrap_or(0));
|
||||
[
|
||||
it.next().unwrap_or(0),
|
||||
it.next().unwrap_or(0),
|
||||
it.next().unwrap_or(0),
|
||||
]
|
||||
}
|
||||
|
||||
fn mob(e: &Value) -> BellEntity {
|
||||
let p = |i: usize| e["pos"][i].as_f64().unwrap_or(0.0);
|
||||
BellEntity {
|
||||
position: [p(0), p(1), p(2)],
|
||||
alive: e["alive"].as_bool().unwrap_or(true) && !e["removed"].as_bool().unwrap_or(false),
|
||||
raider: e["raider"].as_bool().unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn lid_text(l: Option<&ChestLid>) -> String {
|
||||
l.map_or("-".to_owned(), |l| {
|
||||
format!("{} {} {}", l.should_be_open, f(l.openness), f(l.o_openness))
|
||||
})
|
||||
}
|
||||
|
||||
/// The case's players: feet positions and whether each is a spectator.
|
||||
fn players(v: &Value) -> Vec<([f64; 3], bool)> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|p| {
|
||||
let c = |i: usize| p["pos"][i].as_f64().unwrap_or(0.0);
|
||||
(
|
||||
[c(0), c(1), c(2)],
|
||||
p["spectator"].as_bool().unwrap_or(false),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn status(s: ShulkerStatus) -> &'static str {
|
||||
match s {
|
||||
ShulkerStatus::Closed => "CLOSED",
|
||||
ShulkerStatus::Opening => "OPENING",
|
||||
ShulkerStatus::Opened => "OPENED",
|
||||
ShulkerStatus::Closing => "CLOSING",
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: block_entity_anim_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let renderer = BlockEntityRenderer::load(root, registry)?;
|
||||
let tags = Items::tags_only(root)?;
|
||||
let frames = tags
|
||||
.block_tags
|
||||
.get("minecraft:conduit_effect_block")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
writeln!(out, "case {}", case["name"].as_str().unwrap_or_default())?;
|
||||
let block = case["block"].as_str().unwrap_or_default().to_owned();
|
||||
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
|
||||
let pos = [
|
||||
int(&case["pos"][0]),
|
||||
int(&case["pos"][1]),
|
||||
int(&case["pos"][2]),
|
||||
];
|
||||
// The neighbourhood as block states (conduits).
|
||||
let mut blocks: HashMap<[i32; 3], String> = HashMap::new();
|
||||
blocks.insert(pos, block.clone());
|
||||
let place = |blocks: &mut HashMap<[i32; 3], String>, set: &Value| {
|
||||
for (k, v) in set.as_object().into_iter().flatten() {
|
||||
let o = offset(k);
|
||||
blocks.insert(
|
||||
[pos[0] + o[0], pos[1] + o[1], pos[2] + o[2]],
|
||||
v.as_str().unwrap_or_default().to_owned(),
|
||||
);
|
||||
}
|
||||
};
|
||||
place(&mut blocks, &case["world"]);
|
||||
let entities: Vec<BellEntity> = case["entities"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(mob)
|
||||
.collect();
|
||||
let target = case.get("target").map(|t| {
|
||||
let e = mob(t);
|
||||
ConduitTarget {
|
||||
position: e.position,
|
||||
eye_y: e.position[1] + f64::from(t["eye"].as_f64().unwrap_or(0.0) as f32),
|
||||
width: t["width"].as_f64().unwrap_or(0.0) as f32,
|
||||
height: t["height"].as_f64().unwrap_or(0.0) as f32,
|
||||
}
|
||||
});
|
||||
let mut random = LegacyRandom::new(case["seed"].as_i64().unwrap_or(0));
|
||||
let name = block.split('[').next().unwrap_or_default().to_owned();
|
||||
let mut anims = BlockEntityAnims::default();
|
||||
let partner = case["partner"].as_str().map(|k| {
|
||||
let o = offset(k);
|
||||
[pos[0] + o[0], pos[1] + o[1], pos[2] + o[2]]
|
||||
});
|
||||
let mut gamers = players(&case["players"]);
|
||||
if name == "minecraft:enchanting_table" {
|
||||
anims.book_random = LegacyRandom::new(case["seed"].as_i64().unwrap_or(0));
|
||||
}
|
||||
let facing = prop(&block, "facing")
|
||||
.and_then(direction_index)
|
||||
.unwrap_or(1);
|
||||
// End gateways: the saved age, the level's height range and the
|
||||
// integrated server's copy.
|
||||
let initial = EndGateway {
|
||||
age: case["age"].as_i64().unwrap_or(0),
|
||||
teleport_cooldown: 0,
|
||||
};
|
||||
let max_y = int(&case["level"][0]) + int(&case["level"][1]) - 1;
|
||||
let mut server = initial;
|
||||
let mut text = String::new();
|
||||
for step in case["steps"].as_array().into_iter().flatten() {
|
||||
let time = step["time"].as_i64().unwrap_or(0);
|
||||
let partial = step["partial"].as_f64().unwrap_or(0.0) as f32;
|
||||
let mut log = String::new();
|
||||
place(&mut blocks, &step["set"]);
|
||||
let event = step["event"].as_array().map(|e| (int(&e[0]), int(&e[1])));
|
||||
text.clear();
|
||||
if step.get("players").is_some() {
|
||||
gamers = players(&step["players"]);
|
||||
}
|
||||
if name.ends_with("chest") {
|
||||
let lid = anims.chests.entry(pos).or_default();
|
||||
if let Some((a, b)) = event {
|
||||
let handled = lid.trigger_event(a, b);
|
||||
let _ = write!(log, " event {handled}");
|
||||
}
|
||||
if let Some(other) = partner {
|
||||
let lid = anims.chests.entry(other).or_default();
|
||||
if let Some(e) = step["partnerEvent"].as_array() {
|
||||
let handled = lid.trigger_event(int(&e[0]), int(&e[1]));
|
||||
let _ = write!(log, " partner event {handled}");
|
||||
}
|
||||
}
|
||||
// lidAnimateTick: this chest, then the other half.
|
||||
if let Some(l) = anims.chests.get_mut(&pos) {
|
||||
l.tick();
|
||||
}
|
||||
if let Some(l) = partner.and_then(|p| anims.chests.get_mut(&p)) {
|
||||
l.tick();
|
||||
}
|
||||
let state_at = |p: [i32; 3]| {
|
||||
find_state(
|
||||
registry,
|
||||
blocks.get(&p).map_or("minecraft:air", String::as_str),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let (open, _) = renderer
|
||||
.chest_openness_of(pos, state_at(pos), &state_at, &|p| anims.lid(p, partial));
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} partner {} render {} {}",
|
||||
lid_text(anims.chests.get(&pos)),
|
||||
partner.map_or("none".to_owned(), |p| lid_text(anims.chests.get(&p))),
|
||||
f(open),
|
||||
f(chest_lid_x_rot(open))
|
||||
);
|
||||
} else if name == "minecraft:enchanting_table" {
|
||||
let feet: Vec<[f64; 3]> = gamers
|
||||
.iter()
|
||||
.filter(|(_, spectator)| !spectator)
|
||||
.map(|(p, _)| *p)
|
||||
.collect();
|
||||
let BlockEntityAnims {
|
||||
books, book_random, ..
|
||||
} = &mut anims;
|
||||
books.entry(pos).or_default().tick(pos, &feet, book_random);
|
||||
let b = anims.books[&pos];
|
||||
let r = anims
|
||||
.block_anim(pos, time, partial, Default::default())
|
||||
.book
|
||||
.render(partial);
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} {} {} {} {} {} {} {} {} render {} {} {} {} book {} {} {}",
|
||||
b.time,
|
||||
f(b.flip),
|
||||
f(b.o_flip),
|
||||
f(b.flip_t),
|
||||
f(b.flip_a),
|
||||
f(b.open),
|
||||
f(b.o_open),
|
||||
f(b.rot),
|
||||
f(b.o_rot),
|
||||
f(b.t_rot),
|
||||
f(r.flip),
|
||||
f(r.open),
|
||||
f(r.time),
|
||||
f(r.y_rot),
|
||||
f(r.openness),
|
||||
f(r.page_flip1),
|
||||
f(r.page_flip2)
|
||||
);
|
||||
} else if name.ends_with("shulker_box") {
|
||||
let s = anims.shulkers.entry(pos).or_insert_with(Shulker::default);
|
||||
if let Some((a, b)) = event {
|
||||
let handled = s.trigger_event(a, b);
|
||||
let _ = write!(log, " event {handled}");
|
||||
}
|
||||
let tick = s.tick(pos, facing);
|
||||
for _ in 0..tick.neighbor_updates {
|
||||
log.push_str(" neighbors");
|
||||
}
|
||||
if let Some(b) = tick.push {
|
||||
let _ = write!(log, " push {}", bx(b));
|
||||
}
|
||||
let (count, st) = (s.open_count, s.status);
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
// ShulkerBoxRenderer: getProgress(partial).
|
||||
let (progress, old) = a.shulker;
|
||||
let render = old + partial * (progress - old);
|
||||
let s = &anims.shulkers[&pos];
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} {} {} render {}",
|
||||
status(st),
|
||||
count,
|
||||
f(s.progress),
|
||||
f(s.progress_old),
|
||||
f(render)
|
||||
);
|
||||
} else if name == "minecraft:bell" {
|
||||
let b = anims.bells.entry(pos).or_insert_with(Bell::default);
|
||||
if let Some((e0, e1)) = event {
|
||||
let handled = b.trigger_event(e0, e1, time, || {
|
||||
let inflated = [
|
||||
f64::from(pos[0]) - 48.0,
|
||||
f64::from(pos[1]) - 48.0,
|
||||
f64::from(pos[2]) - 48.0,
|
||||
f64::from(pos[0]) + 1.0 + 48.0,
|
||||
f64::from(pos[1]) + 1.0 + 48.0,
|
||||
f64::from(pos[2]) + 1.0 + 48.0,
|
||||
];
|
||||
let _ = write!(log, " gather {}", bx(inflated));
|
||||
(0..entities.len() as u64)
|
||||
.filter(|&i| {
|
||||
let p = entities[i as usize].position;
|
||||
(0..3).all(|a| p[a] >= inflated[a] && p[a] < inflated[a + 3])
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let _ = write!(log, " event {handled}");
|
||||
}
|
||||
if !step["noTick"].as_bool().unwrap_or(false) {
|
||||
let tick = b.tick(pos, |h| entities.get(h as usize).copied());
|
||||
if tick.resonate {
|
||||
log.push_str(" sound minecraft:block.bell.resonate");
|
||||
}
|
||||
if let Some((particles, _)) = tick.ended {
|
||||
for (p, color) in particles {
|
||||
let _ = write!(
|
||||
log,
|
||||
" particle minecraft:entity_effect {color} {} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
d(0.0),
|
||||
d(0.0),
|
||||
d(0.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let b = &anims.bells[&pos];
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
let dir = |d: Option<&str>| d.map_or("null".to_owned(), str::to_owned);
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} {} {} {} {} render {} {}",
|
||||
b.ticks,
|
||||
b.shaking,
|
||||
dir(b.click_direction.map(|d| DIRECTIONS[usize::from(d)])),
|
||||
b.resonating,
|
||||
b.resonation_ticks,
|
||||
b.last_ring_timestamp,
|
||||
f(a.bell_ticks as f32 + partial),
|
||||
dir(a.bell_shake)
|
||||
);
|
||||
} else if name == "minecraft:conduit" {
|
||||
let c = anims.conduits.entry(pos).or_insert_with(Conduit::default);
|
||||
let state_of = |p: [i32; 3]| -> Option<u32> {
|
||||
find_state(
|
||||
registry,
|
||||
blocks.get(&p).map_or("minecraft:air", String::as_str),
|
||||
)
|
||||
};
|
||||
let is_water = |p: [i32; 3]| {
|
||||
state_of(p).is_some_and(|s| facts.get(BlockStateId(s)).fluid == Fluid::Water)
|
||||
};
|
||||
let is_frame = |p: [i32; 3]| {
|
||||
let spec = blocks.get(&p).map_or("minecraft:air", String::as_str);
|
||||
frames.contains(spec.split('[').next().unwrap_or_default())
|
||||
};
|
||||
let particles = c.client_tick(pos, time, is_water, is_frame, target, &mut random);
|
||||
for (p, v) in particles {
|
||||
let _ = write!(
|
||||
log,
|
||||
" particle minecraft:nautilus {} {} {} {} {} {}",
|
||||
d(p[0]),
|
||||
d(p[1]),
|
||||
d(p[2]),
|
||||
d(v[0]),
|
||||
d(v[1]),
|
||||
d(v[2])
|
||||
);
|
||||
}
|
||||
let c = &anims.conduits[&pos];
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
// ConduitRenderer.extractRenderState as block_entity_render derives it.
|
||||
let (ticks, active, hunting, raw) = a.conduit;
|
||||
let rotation = (raw + if active { partial } else { 0.0 }) * -0.0375;
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} {} {} {} render {} {} {} {} {}",
|
||||
c.tick_count,
|
||||
c.is_active,
|
||||
c.is_hunting,
|
||||
f(c.active_rotation),
|
||||
c.effect_blocks.len(),
|
||||
active,
|
||||
f(rotation),
|
||||
f(ticks as f32 + partial),
|
||||
ticks / 66 % 3,
|
||||
hunting
|
||||
);
|
||||
} else if name.ends_with("_head") || name.ends_with("_skull") {
|
||||
let now = step["state"].as_str().unwrap_or(&block);
|
||||
let s = anims.skulls.entry(pos).or_insert_with(Skull::default);
|
||||
s.tick(prop(now, "powered") == Some("true"));
|
||||
let s = anims.skulls[&pos];
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
// block_entity_render: count + partial while animating.
|
||||
let (animating, count) = a.skull;
|
||||
let render = count as f32 + if animating { partial } else { 0.0 };
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} render {}",
|
||||
s.animating,
|
||||
s.tick_count,
|
||||
f(render)
|
||||
);
|
||||
} else if name == "minecraft:decorated_pot" {
|
||||
let w = anims.pots.entry(pos).or_insert_with(PotWobble::default);
|
||||
if let Some((e0, e1)) = event {
|
||||
let handled = w.trigger_event(e0, e1, time);
|
||||
let _ = write!(log, " event {handled}");
|
||||
}
|
||||
let w = anims.pots[&pos];
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
let (style, progress) = match a.wobble {
|
||||
Some((true, p)) => ("POSITIVE", p),
|
||||
Some((false, p)) => ("null", p),
|
||||
None => ("null", 0.0),
|
||||
};
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} render {} {}",
|
||||
w.started_at,
|
||||
match w.style {
|
||||
Some(WobbleStyle::Positive) => "POSITIVE",
|
||||
Some(WobbleStyle::Negative) => "NEGATIVE",
|
||||
None => "null",
|
||||
},
|
||||
f(progress),
|
||||
style
|
||||
);
|
||||
} else if name == "minecraft:end_gateway" {
|
||||
let client = anims.gateways.entry(pos).or_insert(initial);
|
||||
if step["enter"].as_bool() == Some(true) && server.enter() {
|
||||
log.push_str(" blockEvent 1 0");
|
||||
client.trigger_event(1, 0);
|
||||
}
|
||||
if server.portal_tick() {
|
||||
log.push_str(" blockEvent 1 0");
|
||||
client.trigger_event(1, 0);
|
||||
}
|
||||
if let Some((e0, e1)) = event {
|
||||
let handled = client.trigger_event(e0, e1);
|
||||
let _ = write!(log, " event {handled}");
|
||||
}
|
||||
client.tick();
|
||||
let g = *client;
|
||||
let a = anims.block_anim(pos, time, partial, Default::default());
|
||||
let b = gateway_beam(a.gateway, partial, max_y, time);
|
||||
let _ = write!(
|
||||
text,
|
||||
"{} {} server {} {} render {} {} {:x} {}",
|
||||
g.age,
|
||||
g.teleport_cooldown,
|
||||
server.age,
|
||||
server.teleport_cooldown,
|
||||
b.height,
|
||||
f(b.scale),
|
||||
b.color,
|
||||
f(b.animation_time)
|
||||
);
|
||||
} else {
|
||||
text = format!("unsupported {block}");
|
||||
}
|
||||
writeln!(out, "{text} |{log}")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! Headless transport for the block-entity render parity gate: replays each
|
||||
//! case (block state, light, partial tick and ticked block-entity fields)
|
||||
//! through `BlockEntityRenderer` at the block origin and writes the quads
|
||||
//! (texture, sprite-local UVs, baked colour, light) and sign text calls.
|
||||
#![forbid(unsafe_code)]
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
banner::{self, Banners},
|
||||
block_entity_anim::EnchantingBook,
|
||||
block_entity_render::{
|
||||
BlockAnim, BlockEntityRenderer, END_GATEWAY_LAYER, END_PORTAL_LAYER, Scene, TextCall,
|
||||
},
|
||||
chunk::{Chunk, Light},
|
||||
hud::{HudAtlas, Rect},
|
||||
item_render::{ItemRenderer, gui},
|
||||
signs::{DYE_TEXT, SignData, SignText},
|
||||
world::World,
|
||||
};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::HashMap,
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
/// Every texture the vanilla oracle used gets its own unit square on a small
|
||||
/// grid (offsets stay small so f32 UVs keep their precision), so a vertex's
|
||||
/// texture and sprite-local UV can be recovered.
|
||||
fn fake_sprites(list: &Path) -> Result<(HashMap<String, Rect>, Vec<String>, usize)> {
|
||||
let names: Vec<String> = fs::read_to_string(list)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let grid = (names.len() as f64).sqrt().ceil().max(1.0) as usize;
|
||||
let map = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let (u0, v0) = ((i % grid) as f32 * 2.0, (i / grid) as f32 * 2.0);
|
||||
(
|
||||
n.clone(),
|
||||
Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1: u0 + 1.0,
|
||||
v1: v0 + 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok((map, names, grid))
|
||||
}
|
||||
|
||||
fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
let wanted: Vec<(&str, &str)> = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.collect();
|
||||
let default = registry.state(registry.default_state(name)?)?.clone();
|
||||
(0..registry.len() as u32).find(|&id| {
|
||||
registry.state(BlockStateId(id)).is_some_and(|s| {
|
||||
s.name == name
|
||||
&& s.properties.iter().all(|(k, v)| {
|
||||
wanted
|
||||
.iter()
|
||||
.find(|(wk, _)| wk == k)
|
||||
.map_or(default.properties.get(k) == Some(v), |(_, wv)| wv == v)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_side(v: &Value) -> SignText {
|
||||
let mut side = SignText::default();
|
||||
for (line, text) in side
|
||||
.lines
|
||||
.iter_mut()
|
||||
.zip(v["lines"].as_array().into_iter().flatten())
|
||||
{
|
||||
*line = text.as_str().unwrap_or_default().to_owned();
|
||||
}
|
||||
let color = v["color"].as_str().unwrap_or("black");
|
||||
side.color = DYE_TEXT.iter().position(|(n, _)| *n == color).unwrap_or(15) as u8;
|
||||
side.glowing = v["glowing"].as_bool().unwrap_or(false);
|
||||
side
|
||||
}
|
||||
|
||||
fn intern(s: &str) -> &'static str {
|
||||
match s {
|
||||
"north" => "north",
|
||||
"south" => "south",
|
||||
"west" => "west",
|
||||
"east" => "east",
|
||||
"up" => "up",
|
||||
_ => "down",
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, textures, output] = args.as_slice() else {
|
||||
return Err("usage: block_entity_render_fixture RESOURCES INPUTS TEXTURES OUTPUT".into());
|
||||
};
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let renderer = BlockEntityRenderer::load(root, registry)?;
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
let banners = Banners::load(root);
|
||||
let items = ItemRenderer::load(root)?;
|
||||
let (sprites, names, grid) = fake_sprites(textures)?;
|
||||
let air = registry.default_state("minecraft:air").ok_or("no air")?;
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let block = case["block"].as_str().unwrap_or_default();
|
||||
if let Some(loom) = case.get("loom") {
|
||||
// The loom's result preview, as LoomScreen draws it at (0, 0).
|
||||
let layers: Vec<(String, String)> = loom["patterns"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|p| {
|
||||
(
|
||||
banners.banner_texture(p[0].as_str().unwrap_or_default()),
|
||||
p[1].as_str().unwrap_or_default().to_owned(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let base = loom["base"].as_str().unwrap_or_default();
|
||||
let flat = gui::items_flat_lights();
|
||||
let quads: Vec<Value> =
|
||||
gui::banner_result_quads(&items, &sprites, 0.0, 0.0, base, &layers)
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let (cu, cv) = ((q.uv[0][0] / 2.0).floor(), (q.uv[0][1] / 2.0).floor());
|
||||
let texture = names
|
||||
.get(cu as usize + cv as usize * grid)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let v: Vec<Value> = (0..4)
|
||||
.map(|k| {
|
||||
let p = q.pos[k];
|
||||
json!([p.x, p.y, p.z, q.uv[k][0] - cu * 2.0, q.uv[k][1] - cv * 2.0])
|
||||
})
|
||||
.collect();
|
||||
// Full bright (15728880).
|
||||
json!({"texture": texture, "v": v, "color": gui::lit_color(q, flat), "light": 0xF0F0})
|
||||
})
|
||||
.collect();
|
||||
writeln!(out, "{}", json!({"quads": quads, "texts": []}))?;
|
||||
continue;
|
||||
}
|
||||
if block == "minecraft:beacon" {
|
||||
// BeaconRenderer: the beam sections from the case, as Horizon's beacon keeps them.
|
||||
let int = |v: &Value| v.as_i64().unwrap_or(0);
|
||||
let pos = [
|
||||
int(&case["pos"][0]),
|
||||
int(&case["pos"][1]),
|
||||
int(&case["pos"][2]),
|
||||
];
|
||||
let partial = case["partial"].as_f64().unwrap_or(0.0) as f32;
|
||||
let time = int(&case["gameTime"]).rem_euclid(40) as f32 + partial;
|
||||
let cam = &case["camera"];
|
||||
let dx = cam[0].as_f64().unwrap_or(0.0) - (pos[0] as f64 + 0.5);
|
||||
let dz = cam[2].as_f64().unwrap_or(0.0) - (pos[2] as f64 + 0.5);
|
||||
let scale = ((dx * dx + dz * dz).sqrt() as f32 / 96.0).max(1.0);
|
||||
let sections: Vec<(u32, i32)> = case["beam"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|s| (int(&s[0]) as u32 & 0xFF_FFFF, int(&s[1]) as i32))
|
||||
.collect();
|
||||
let texture = "entity/beacon/beacon_beam";
|
||||
let rect = sprites.get(texture).copied().ok_or("no beam sprite")?;
|
||||
let mut verts = Vec::new();
|
||||
horizon_client::block_entity_render::beacon_beam(
|
||||
Vec3::ZERO,
|
||||
§ions,
|
||||
time,
|
||||
scale,
|
||||
rect,
|
||||
&mut verts,
|
||||
);
|
||||
let quads: Vec<Value> = verts
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0] - rect.u0, v.uv[1]]))
|
||||
.collect();
|
||||
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light, "custom": true})
|
||||
})
|
||||
.collect();
|
||||
writeln!(out, "{}", json!({"quads": quads, "texts": []}))?;
|
||||
continue;
|
||||
}
|
||||
let Some(state) = find_state(registry, block) else {
|
||||
writeln!(out, "{}", json!({"skip": "unknown block state"}))?;
|
||||
continue;
|
||||
};
|
||||
if !renderer.renders(state) {
|
||||
writeln!(out, "{}", json!({"skip": "no renderer"}))?;
|
||||
continue;
|
||||
}
|
||||
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
|
||||
let float = |v: &Value| v.as_f64().unwrap_or(0.0) as f32;
|
||||
let pos = [
|
||||
int(&case["pos"][0]),
|
||||
int(&case["pos"][1]),
|
||||
int(&case["pos"][2]),
|
||||
];
|
||||
let packed = (int(&case["light"][0]) << 4 | int(&case["light"][1])) as u8;
|
||||
let mut world = World::new(air, 0);
|
||||
for dx in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
let (cx, cz) = ((pos[0] >> 4) + dx, (pos[2] >> 4) + dz);
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for section in &mut chunk.sections {
|
||||
Arc::make_mut(section).light = Light::Uniform(packed);
|
||||
}
|
||||
world.chunks.insert((cx, cz), Arc::new(chunk));
|
||||
}
|
||||
}
|
||||
// Neighbour blocks the case sets (End gateway faces).
|
||||
for (direction, spec) in case["neighbours"].as_object().into_iter().flatten() {
|
||||
let d = match direction.as_str() {
|
||||
"down" => [0, -1, 0],
|
||||
"up" => [0, 1, 0],
|
||||
"north" => [0, 0, -1],
|
||||
"south" => [0, 0, 1],
|
||||
"west" => [-1, 0, 0],
|
||||
_ => [1, 0, 0],
|
||||
};
|
||||
let neighbour = find_state(registry, spec.as_str().unwrap_or_default())
|
||||
.ok_or("unknown neighbour state")?;
|
||||
world.set(
|
||||
pos[0] + d[0],
|
||||
pos[1] + d[1],
|
||||
pos[2] + d[2],
|
||||
BlockStateId(neighbour),
|
||||
)?;
|
||||
}
|
||||
let book = case
|
||||
.get("book")
|
||||
.map_or_else(EnchantingBook::default, |book| {
|
||||
let [flip, o_flip, open, o_open, rot, o_rot] =
|
||||
["flip", "oFlip", "open", "oOpen", "rot", "oRot"].map(|k| float(&book[k]));
|
||||
EnchantingBook {
|
||||
time: int(&book["time"]),
|
||||
flip,
|
||||
o_flip,
|
||||
open,
|
||||
o_open,
|
||||
rot,
|
||||
o_rot,
|
||||
..EnchantingBook::default()
|
||||
}
|
||||
});
|
||||
let sign = case.get("sign").map(|s| {
|
||||
let mut data = SignData {
|
||||
hanging: block.contains("hanging"),
|
||||
..SignData::default()
|
||||
};
|
||||
data.front = sign_side(&s["front"]);
|
||||
data.back = sign_side(&s["back"]);
|
||||
data
|
||||
});
|
||||
let layers: Vec<(String, u32)> = case["patterns"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|p| {
|
||||
(
|
||||
banners.banner_texture(p[0].as_str().unwrap_or_default()),
|
||||
banner::dye_rgb(p[1].as_str().unwrap_or_default()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let anim = BlockAnim {
|
||||
shulker: (float(&case["progress"]), float(&case["progressOld"])),
|
||||
bell_ticks: int(&case["bellTicks"]),
|
||||
bell_shake: case["shake"].as_str().map(intern),
|
||||
conduit: (
|
||||
int(&case["conduit"]["tickCount"]),
|
||||
case["conduit"]["active"].as_bool().unwrap_or(false),
|
||||
case["conduit"]["hunting"].as_bool().unwrap_or(false),
|
||||
float(&case["conduit"]["activeRotation"]),
|
||||
),
|
||||
skull: (
|
||||
case["skullAnimation"][0].as_bool().unwrap_or(false),
|
||||
int(&case["skullAnimation"][1]),
|
||||
),
|
||||
sherds: [0, 1, 2, 3].map(|i| {
|
||||
case["sherds"][i]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
}),
|
||||
wobble: case["wobble"]
|
||||
.as_array()
|
||||
.map(|w| (w[0].as_str() == Some("POSITIVE"), float(&w[1]))),
|
||||
book,
|
||||
gateway: (
|
||||
case["gateway"]["age"].as_i64().unwrap_or(0),
|
||||
int(&case["gateway"]["cooldown"]),
|
||||
),
|
||||
};
|
||||
// The case's level height (minimum y, height): the End's by default.
|
||||
let max_y = match case["level"].as_array() {
|
||||
Some(l) => int(&l[0]) + int(&l[1]) - 1,
|
||||
None => 255,
|
||||
};
|
||||
let open = float(&case["open"]);
|
||||
let near = case["sign"]["near"].as_bool().unwrap_or(false);
|
||||
let camera = DVec3::new(
|
||||
case["camera"][0].as_f64().unwrap_or(0.0),
|
||||
case["camera"][1].as_f64().unwrap_or(0.0),
|
||||
case["camera"][2].as_f64().unwrap_or(0.0),
|
||||
);
|
||||
// Signs pick the outline from the camera distance: put the camera where the case says.
|
||||
let camera = if case.get("sign").is_some() {
|
||||
let centre = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])) + 0.5;
|
||||
centre
|
||||
+ if near {
|
||||
DVec3::ZERO
|
||||
} else {
|
||||
DVec3::new(20.0, 0.0, 0.0)
|
||||
}
|
||||
} else {
|
||||
camera
|
||||
};
|
||||
let (yaw, pitch) = (float(&case["yaw"]), float(&case["pitch"]));
|
||||
let texts = RefCell::new(Vec::<TextCall>::new());
|
||||
let scene = Scene {
|
||||
camera,
|
||||
orientation: Quat::from_euler(
|
||||
glam::EulerRot::YXZ,
|
||||
std::f32::consts::PI - yaw * (std::f32::consts::PI / 180.0),
|
||||
-pitch * (std::f32::consts::PI / 180.0),
|
||||
0.0,
|
||||
),
|
||||
anim: &|_| anim.clone(),
|
||||
texts: Some(&texts),
|
||||
partial: float(&case["partial"]),
|
||||
game_time: case["gameTime"].as_i64().unwrap_or(0),
|
||||
max_y,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| open,
|
||||
sign: &|_| sign.as_ref(),
|
||||
banner: &|_| layers.clone(),
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
|
||||
let quads: Vec<Value> = verts
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
// Custom geometry: the End portal shader's position-only cube (its
|
||||
// render type samples end_sky first) and End gateway beams.
|
||||
if q[0].layer == END_PORTAL_LAYER || q[0].layer == END_GATEWAY_LAYER {
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0], v.uv[1]]))
|
||||
.collect();
|
||||
return json!({"texture": "environment/end_sky", "v": v, "color": q[0].color,
|
||||
"light": q[0].light, "custom": true});
|
||||
}
|
||||
if q[0].layer == 6 {
|
||||
let texture = "entity/end_portal/end_gateway_beam";
|
||||
let u0 = sprites.get(texture).map_or(0.0, |r| r.u0);
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0] - u0, v.uv[1]]))
|
||||
.collect();
|
||||
return json!({"texture": texture, "v": v, "color": q[0].color,
|
||||
"light": q[0].light, "custom": true});
|
||||
}
|
||||
let (cu, cv) = ((q[0].uv[0] / 2.0).floor(), (q[0].uv[1] / 2.0).floor());
|
||||
let texture = names
|
||||
.get(cu as usize + cv as usize * grid)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let v: Vec<Value> = q
|
||||
.iter()
|
||||
.map(|v| {
|
||||
json!([
|
||||
v.pos[0],
|
||||
v.pos[1],
|
||||
v.pos[2],
|
||||
v.uv[0] - cu * 2.0,
|
||||
v.uv[1] - cv * 2.0
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light})
|
||||
})
|
||||
.collect();
|
||||
let texts: Vec<Value> = texts
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|t| !t.text.is_empty())
|
||||
.map(|t| {
|
||||
json!({"matrix": t.matrix.to_cols_array(), "x": t.x, "y": t.y, "text": t.text,
|
||||
"color": t.color, "outline": t.outline,
|
||||
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF))})
|
||||
})
|
||||
.collect();
|
||||
writeln!(out, "{}", json!({"quads": quads, "texts": texts}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs the JSON-lines scenarios of tools/test_camera_parity.py through the
|
||||
//! Rust camera, field-of-view, avatar bob and view/hurt bobbing code.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
camera::{
|
||||
AvatarBob, BobState, Camera, CameraEntity, CameraFluid, CameraType, FovPlayer, HurtBob,
|
||||
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
convert::Infallible,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn fl(v: &Value) -> f32 {
|
||||
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn db(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn fb(v: f32) -> String {
|
||||
format!("{:08x}", if v.is_nan() { 0x7fc0_0000 } else { v.to_bits() })
|
||||
}
|
||||
fn dbs(v: f64) -> String {
|
||||
format!(
|
||||
"{:016x}",
|
||||
if v.is_nan() {
|
||||
0x7ff8_0000_0000_0000
|
||||
} else {
|
||||
v.to_bits()
|
||||
}
|
||||
)
|
||||
}
|
||||
fn b(v: &Value) -> bool {
|
||||
v.as_bool().unwrap()
|
||||
}
|
||||
fn vec3(v: &Value) -> [f64; 3] {
|
||||
[db(&v[0]), db(&v[1]), db(&v[2])]
|
||||
}
|
||||
|
||||
fn fov_player(c: &Value) -> FovPlayer {
|
||||
FovPlayer {
|
||||
flying: b(&c["flying"]),
|
||||
walking_speed: fl(&c["ws"]),
|
||||
movement_speed: db(&c["speed"]),
|
||||
using: c["use"].as_array().map(|u| {
|
||||
let item = match u[0].as_str().unwrap() {
|
||||
"bow" => UseItem::Bow,
|
||||
"spyglass" => UseItem::Spyglass,
|
||||
_ => UseItem::Other,
|
||||
};
|
||||
(item, u[1].as_i64().unwrap() as i32)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A level of visual shapes keyed by cell, without fluids.
|
||||
struct Cells {
|
||||
shapes: Vec<VoxelShape>,
|
||||
index: HashMap<BlockPos, usize>,
|
||||
}
|
||||
impl RayWorld for Cells {
|
||||
type Error = Infallible;
|
||||
type Block = usize;
|
||||
type Fluid = ();
|
||||
fn block_state(&mut self, p: BlockPos) -> std::result::Result<usize, Infallible> {
|
||||
Ok(self.index.get(&p).copied().unwrap_or(0))
|
||||
}
|
||||
fn fluid_state(&mut self, _: BlockPos) -> std::result::Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
fn block_shape(
|
||||
&mut self,
|
||||
s: usize,
|
||||
_: BlockPos,
|
||||
_: RayBlockMode,
|
||||
) -> std::result::Result<VoxelShape, Infallible> {
|
||||
Ok(self.shapes[s].clone())
|
||||
}
|
||||
fn interaction_shape(
|
||||
&mut self,
|
||||
_: usize,
|
||||
_: BlockPos,
|
||||
) -> std::result::Result<VoxelShape, Infallible> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
fn fluid_is_source(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
|
||||
Ok(false)
|
||||
}
|
||||
fn fluid_is_empty(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
|
||||
Ok(true)
|
||||
}
|
||||
fn fluid_is_water(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
|
||||
Ok(false)
|
||||
}
|
||||
fn fluid_shape(&mut self, _: (), _: BlockPos) -> std::result::Result<VoxelShape, Infallible> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn cells(c: &Value) -> Result<Cells> {
|
||||
let mut world = Cells {
|
||||
shapes: vec![VoxelShape::empty()],
|
||||
index: HashMap::new(),
|
||||
};
|
||||
for (key, boxes) in c["cells"].as_object().unwrap() {
|
||||
let at: Vec<i32> = key.split(' ').map(|v| v.parse().unwrap()).collect();
|
||||
let mut shape = VoxelShape::empty();
|
||||
for a in boxes.as_array().unwrap() {
|
||||
shape = shape.join(
|
||||
&VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(db(&a[0]), db(&a[1]), db(&a[2])),
|
||||
Vec3::new(db(&a[3]), db(&a[4]), db(&a[5])),
|
||||
))?,
|
||||
ShapeOp::OR,
|
||||
)?;
|
||||
}
|
||||
world
|
||||
.index
|
||||
.insert(BlockPos::new(at[0], at[1], at[2]), world.shapes.len());
|
||||
world.shapes.push(shape);
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
fn camera_type(v: &Value) -> CameraType {
|
||||
[
|
||||
CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack,
|
||||
CameraType::ThirdPersonFront,
|
||||
][v.as_u64().unwrap() as usize]
|
||||
}
|
||||
|
||||
/// The camera entity and, for players, what the field of view reads.
|
||||
fn entity(s: &Value) -> (CameraEntity, Option<FovPlayer>) {
|
||||
let player = s["kind"] == "player";
|
||||
let living = player.then(|| LivingCamera {
|
||||
scale: db(&s["scale_attr"]) as f32,
|
||||
camera_distance: db(&s["dist"]),
|
||||
sleeping: s["sleeping"].as_array().map(|a| {
|
||||
a[0].as_u64()
|
||||
.map(|d| [0.0f32, 90.0, 180.0, 270.0][d as usize])
|
||||
}),
|
||||
dead: s["dead"].as_i64().map(|t| t as i32),
|
||||
});
|
||||
let mount = if player {
|
||||
s["mount"].as_array().map(|a| (db(&a[0]) as f32, db(&a[1])))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
CameraEntity {
|
||||
position: vec3(&s["pos"]),
|
||||
previous: vec3(&s["prev"]),
|
||||
x_rot: fl(&s["xr"]),
|
||||
y_rot: fl(&s["yr"]),
|
||||
x_rot_o: fl(&s["xro"]),
|
||||
y_rot_o: fl(&s["yro"]),
|
||||
eye_height: fl(&s["eye"]),
|
||||
living,
|
||||
mount,
|
||||
},
|
||||
player.then(|| fov_player(s)),
|
||||
)
|
||||
}
|
||||
|
||||
fn camera(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let mut world = cells(c)?;
|
||||
let fov_option = c["fov"].as_i64().unwrap() as i32;
|
||||
let effect = db(&c["effect"]) as f32;
|
||||
let mut kind = camera_type(&c["type"]);
|
||||
let mut camera = Camera::default();
|
||||
let mut current: Option<(CameraEntity, Option<FovPlayer>)> = None;
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
match op[0].as_str().unwrap() {
|
||||
"entity" => {
|
||||
current = Some(entity(&op[1]));
|
||||
writeln!(out, "entity")?;
|
||||
}
|
||||
"type" => {
|
||||
kind = camera_type(&op[1]);
|
||||
writeln!(out, "type")?;
|
||||
}
|
||||
"tick" => {
|
||||
let target = current
|
||||
.as_ref()
|
||||
.and_then(|(_, p)| p.as_ref())
|
||||
.map_or(1.0, |p| {
|
||||
field_of_view_modifier(p, kind.is_first_person(), effect)
|
||||
});
|
||||
camera.tick(current.as_ref().map(|(e, _)| e.eye_height), target);
|
||||
writeln!(
|
||||
out,
|
||||
"tick {} {} {} {}",
|
||||
fb(camera.eye_height),
|
||||
fb(camera.eye_height_old),
|
||||
fb(camera.fov_modifier),
|
||||
fb(camera.old_fov_modifier)
|
||||
)?;
|
||||
}
|
||||
"frame" => {
|
||||
let pt = fl(&op[1]);
|
||||
let fluid = [
|
||||
CameraFluid::None,
|
||||
CameraFluid::Water,
|
||||
CameraFluid::Lava,
|
||||
CameraFluid::PowderSnow,
|
||||
][op[2].as_u64().unwrap() as usize];
|
||||
let panoramic = b(&op[3]);
|
||||
let (e, _) = current.as_ref().expect("entity");
|
||||
camera.align_with_entity(e, pt, kind, |from, to| {
|
||||
let Ok(hit) = clip_world(
|
||||
&mut world,
|
||||
Vec3::new(from[0], from[1], from[2]),
|
||||
Vec3::new(to[0], to[1], to[2]),
|
||||
RayBlockMode::Visual,
|
||||
RayFluidMode::None,
|
||||
usize::MAX,
|
||||
) else {
|
||||
panic!("ray")
|
||||
};
|
||||
(!hit.miss).then(|| {
|
||||
let l = hit.hit.location;
|
||||
[l.x, l.y, l.z]
|
||||
})
|
||||
});
|
||||
let dead = e.living.and_then(|l| l.dead);
|
||||
let fov = camera.calculate_fov(pt, fov_option, effect, dead, fluid, panoramic);
|
||||
let hud = camera.calculate_hud_fov(pt, effect, dead, fluid);
|
||||
let p = camera.position;
|
||||
let q = camera.rotation;
|
||||
let v = |a: [f32; 3]| format!("{} {} {}", fb(a[0]), fb(a[1]), fb(a[2]));
|
||||
writeln!(
|
||||
out,
|
||||
"frame {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
|
||||
dbs(p[0]),
|
||||
dbs(p[1]),
|
||||
dbs(p[2]),
|
||||
fb(camera.x_rot),
|
||||
fb(camera.y_rot),
|
||||
fb(q.x),
|
||||
fb(q.y),
|
||||
fb(q.z),
|
||||
fb(q.w),
|
||||
v(camera.forwards),
|
||||
v(camera.up),
|
||||
v(camera.left),
|
||||
camera.detached,
|
||||
fb(fov),
|
||||
fb(hud)
|
||||
)?;
|
||||
}
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bob(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let state = BobState {
|
||||
living: (!c["living"].is_null()).then(|| {
|
||||
let l = &c["living"];
|
||||
HurtBob {
|
||||
hurt_time: fl(&l["hurt"]),
|
||||
hurt_duration: l["duration"].as_i64().unwrap() as i32,
|
||||
hurt_dir: fl(&l["dir"]),
|
||||
death_time: (!l["death"].is_null()).then(|| fl(&l["death"])),
|
||||
}
|
||||
}),
|
||||
player: c["player"].as_array().map(|p| (fl(&p[0]), fl(&p[1]))),
|
||||
};
|
||||
let m = bob_matrix(&state, db(&c["tilt"]), b(&c["view"]));
|
||||
let mut line = String::from("bob");
|
||||
for v in m.m {
|
||||
line.push(' ');
|
||||
line.push_str(&fb(v));
|
||||
}
|
||||
writeln!(out, "{line} {}", m.properties)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
let mut state = AvatarBob::default();
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
let name = op[0].as_str().unwrap();
|
||||
match name {
|
||||
"tick" => state.tick(),
|
||||
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
|
||||
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
|
||||
"reset" => state.reset_bob(),
|
||||
"frame" => {
|
||||
let pt = fl(&op[1]);
|
||||
writeln!(
|
||||
out,
|
||||
"frame {} {}",
|
||||
fb(state.interpolated_bob(pt)),
|
||||
fb(state.backwards_interpolated_walk_distance(pt))
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
writeln!(out, "{name}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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}")?;
|
||||
match c["t"].as_str().unwrap() {
|
||||
"fov" => writeln!(
|
||||
out,
|
||||
"{}",
|
||||
fb(field_of_view_modifier(
|
||||
&fov_player(&c),
|
||||
b(&c["fp"]),
|
||||
fl(&c["scale"])
|
||||
))
|
||||
)?,
|
||||
"camera" => camera(&c, &mut out)?,
|
||||
"bob" => bob(&c, &mut out)?,
|
||||
"avatar" => avatar(&c, &mut out)?,
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -80,10 +80,13 @@ fn main() -> horizon_client::Result<()> {
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
// The inventory menu never reads banner patterns.
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -54,11 +54,14 @@ fn parser_id(ty: &ArgType) -> String {
|
||||
ArgType::ResOrTagKey(_) => "minecraft:resource_or_tag_key".into(),
|
||||
ArgType::StrEnum(values, _) => match values.first().copied() {
|
||||
Some("world_surface") => "minecraft:heightmap".into(),
|
||||
Some("none") if values.len() == 3 && values[1] == "left_right" => "minecraft:template_mirror".into(),
|
||||
Some("none") if values.len() == 3 && values[1] == "left_right" => {
|
||||
"minecraft:template_mirror".into()
|
||||
}
|
||||
Some("none") if values.len() == 3 => "minecraft:swing_animation".into(),
|
||||
_ => "minecraft:template_rotation".into(),
|
||||
},
|
||||
ArgType::HexColor => "minecraft:hex_color".into(),
|
||||
ArgType::ResourceSelector(_) => "minecraft:resource_selector".into(),
|
||||
ArgType::Uuid => "minecraft:uuid".into(),
|
||||
ArgType::GameProfile => "minecraft:game_profile".into(),
|
||||
ArgType::Style => "minecraft:style".into(),
|
||||
@@ -118,7 +121,8 @@ fn tree(d: &Dispatcher, node: usize) -> Value {
|
||||
}
|
||||
children.insert(child.name().to_owned(), v);
|
||||
}
|
||||
let mut v = json!({"children": children});
|
||||
let order: Vec<&str> = n.children.iter().map(|&c| d.nodes[c].name()).collect();
|
||||
let mut v = json!({"children": children, "order": order});
|
||||
if n.command.is_some() {
|
||||
v["executable"] = json!(true);
|
||||
}
|
||||
@@ -194,10 +198,12 @@ fn main() -> horizon_client::Result<()> {
|
||||
reg.persistent_components = strings(&r["persistent_components"]);
|
||||
reg.learnable_recipes = strings(&r["learnable_recipes"]);
|
||||
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
|
||||
reg.clock_time_markers.insert(clock.clone(), strings(markers));
|
||||
reg.clock_time_markers
|
||||
.insert(clock.clone(), strings(markers));
|
||||
}
|
||||
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
|
||||
reg.advancement_criteria.insert(id.clone(), strings(criteria));
|
||||
reg.advancement_criteria
|
||||
.insert(id.clone(), strings(criteria));
|
||||
}
|
||||
reg.datapacks_enabled = strings(&r["datapacks_selected"]);
|
||||
let enabled = reg.datapacks_enabled.clone();
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::mobs::{
|
||||
anim::{self, Poses, State},
|
||||
aquatic, body_anim as b_anim, equine, guardian,
|
||||
keyframes::AnimationState,
|
||||
shulker, species, tame,
|
||||
shulker, sniffer, species, tame,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
@@ -173,6 +173,56 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
mob.anim.states[i].started = case[k].as_f64().map(|v| v as f32);
|
||||
}
|
||||
}
|
||||
Species::Breeze => {
|
||||
// breeze::anim's state slots.
|
||||
for (i, k) in ["idle", "shoot", "slide", "slideBack", "inhale", "longJump"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
mob.anim.states[i].started = case[k].as_f64().map(|v| v as f32);
|
||||
}
|
||||
}
|
||||
Species::Sniffer => {
|
||||
if flag("isSearching") {
|
||||
mob.extra.variant = sniffer::SEARCHING;
|
||||
}
|
||||
// Sniffer's animation states, in `sniffer::anim`'s order.
|
||||
for (i, k) in [
|
||||
"feelingHappyAnimationState",
|
||||
"scentingAnimationState",
|
||||
"sniffingAnimationState",
|
||||
"diggingAnimationState",
|
||||
"risingAnimationState",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
mob.anim.states[i].started = case[k].as_f64().map(|v| v as f32);
|
||||
}
|
||||
}
|
||||
Species::HappyGhast => {
|
||||
mob.extra.head = case["bodyItem"].as_str().map(Into::into);
|
||||
if flag("isRidden") {
|
||||
mob.rider = Some(Default::default());
|
||||
}
|
||||
}
|
||||
Species::Warden => {
|
||||
use horizon_client::mobs::warden;
|
||||
mob.anim.v[warden::TENDRIL] = num("tendrilAnimation") * 10.0;
|
||||
mob.anim.vo[warden::TENDRIL] = num("tendrilAnimation") * 10.0;
|
||||
mob.anim.v[warden::HEART] = num("heartAnimation") * 10.0;
|
||||
mob.anim.vo[warden::HEART] = num("heartAnimation") * 10.0;
|
||||
for (i, k) in [
|
||||
(warden::ROAR, "roarAnimationState"),
|
||||
(warden::SNIFF, "sniffAnimationState"),
|
||||
(warden::EMERGE, "emergeAnimationState"),
|
||||
(warden::DIGGING, "diggingAnimationState"),
|
||||
(warden::ATTACK, "attackAnimationState"),
|
||||
(warden::SONIC_BOOM, "sonicBoomAnimationState"),
|
||||
] {
|
||||
mob.anim.states[i].started = case[k].as_f64().map(|v| v as f32);
|
||||
}
|
||||
}
|
||||
Species::Witch if flag("isHoldingItem") => {
|
||||
mob.extra.main_hand = Some("minecraft:potion".into());
|
||||
}
|
||||
@@ -215,6 +265,13 @@ fn main() -> horizon_client::Result<()> {
|
||||
base = base.replace(cut, "");
|
||||
}
|
||||
let base = base.replace("undead_horse#", "zombie_horse#");
|
||||
// The breeze's wind and eyes and the creaking's eyes are layers of
|
||||
// the species' own model class; LlamaDecorLayer's model animates like
|
||||
// the llama.
|
||||
let base = base
|
||||
.replace("#wind", "#main")
|
||||
.replace("#eyes", "#main")
|
||||
.replace("#decor", "#main");
|
||||
let base = base.trim_start_matches("cold_").trim_start_matches("warm_");
|
||||
let Some(def) = species::ALL.iter().find(|d| {
|
||||
d.model == layer
|
||||
@@ -222,6 +279,11 @@ fn main() -> horizon_client::Result<()> {
|
||||
|| d.model == base
|
||||
|| d.model.trim_end_matches("#main") == base.trim_end_matches("#main")
|
||||
|| norm(d.model) == base
|
||||
// The warden's emissive layers are its model over other parts.
|
||||
|| (["#tendrils", "#heart", "#bioluminescent", "#pulsating_spots"]
|
||||
.iter()
|
||||
.any(|s| layer.ends_with(s))
|
||||
&& d.model == format!("{}#main", layer.split('#').next().unwrap_or_default()))
|
||||
}) else {
|
||||
out.push_str(&format!("{}\n", json!({"skip": "no species"})));
|
||||
continue;
|
||||
@@ -370,6 +432,22 @@ fn main() -> horizon_client::Result<()> {
|
||||
},
|
||||
&animations,
|
||||
),
|
||||
Species::CopperGolem => {
|
||||
// CopperGolemRenderState's animation states, in the golem's slots.
|
||||
for (slot, key) in [
|
||||
"idleAnimationState",
|
||||
"interactionGetItem",
|
||||
"interactionGetNoItem",
|
||||
"interactionDropItem",
|
||||
"interactionDropNoItem",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
mob.anim.states[slot] = started(key);
|
||||
}
|
||||
horizon_client::mobs::copper_golem::anim(&mob, s, p, &animations);
|
||||
}
|
||||
Species::Rabbit => b_anim::rabbit(
|
||||
p,
|
||||
s,
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Entity death drops for differential tests against vanilla
|
||||
//! (tools/test_entity_death_loot_parity.py): reads the cases of
|
||||
//! `VanillaEntityDeathLootOracle` and prints what `mobs::drops::die` drops.
|
||||
//!
|
||||
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
/// `id[;enchantment:level...]`.
|
||||
fn stack(spec: &str) -> LootStack {
|
||||
let mut parts = spec.split(';');
|
||||
let mut s = LootStack {
|
||||
item: parts.next().unwrap_or("minecraft:air").to_owned(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
enchantments: Vec::new(),
|
||||
potion: None,
|
||||
map: false,
|
||||
ominous_amplifier: None,
|
||||
};
|
||||
for e in parts {
|
||||
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
|
||||
let name = if name.contains(':') {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
s.enchantments.push((name, level.parse().unwrap_or(1)));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn line(s: &LootStack) -> String {
|
||||
let mut out = format!("drop {} {}", s.item, s.count);
|
||||
if s.damage != 0 {
|
||||
let _ = write!(out, " damage={}", s.damage);
|
||||
}
|
||||
for (e, l) in &s.enchantments {
|
||||
let _ = write!(out, " ench={e}:{l}");
|
||||
}
|
||||
if let Some(p) = &s.potion {
|
||||
let _ = write!(out, " potion={p}");
|
||||
}
|
||||
if let Some(a) = s.ominous_amplifier {
|
||||
let _ = write!(out, " ominous={a}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(
|
||||
args.get(1)
|
||||
.ok_or("usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let loot = LootData::load(root)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() || raw.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let mut c = BTreeMap::new();
|
||||
let mut name = "";
|
||||
for token in raw.split_whitespace() {
|
||||
match token.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
c.insert(k, v);
|
||||
}
|
||||
None => name = token,
|
||||
}
|
||||
}
|
||||
let get =
|
||||
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
|
||||
let _ = writeln!(out, "== {name}");
|
||||
let Some(kind) = types.types.get(c["type"]) else {
|
||||
let _ = writeln!(out, "error unsupported {}", c["type"]);
|
||||
continue;
|
||||
};
|
||||
let kind = Arc::new(kind.clone());
|
||||
let world_seed: i64 = get("seed", "0").parse()?;
|
||||
let kills: i64 = get("kills", "1").parse()?;
|
||||
let rand: i64 = get("rand", "0").parse()?;
|
||||
let mut sequences = RandomSequences::default();
|
||||
for k in 0..kills {
|
||||
let _ = writeln!(out, "kill {k}");
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
// EntityType.create(LOAD) gives no equipment.
|
||||
for slot in drops::SLOTS {
|
||||
drops::clear_slot(&mut m, slot);
|
||||
}
|
||||
if get("baby", "0") == "1" && drops::can_be_baby(m.species()) {
|
||||
m.extra.age = -24000;
|
||||
}
|
||||
if get("fire", "0") == "1" {
|
||||
m.fire_ticks = 100;
|
||||
}
|
||||
// A cube made with EntityType.create(LOAD) was never sized.
|
||||
if matches!(m.species(), Species::Slime | Species::MagmaCube) {
|
||||
m.extra.size = 0;
|
||||
}
|
||||
if let Some(size) = c.get("size") {
|
||||
let size: i32 = size.parse()?;
|
||||
if m.species() == Species::SulfurCube {
|
||||
sulfur_cube::set_size(&mut m, size, true);
|
||||
} else {
|
||||
cube::set_size(&mut m, size);
|
||||
}
|
||||
}
|
||||
if let Some(color) = c.get("color") {
|
||||
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
|
||||
}
|
||||
m.extra.sheared = get("sheared", "0") == "1";
|
||||
if get("leader", "0") == "1" {
|
||||
raider::state_mut(&mut m).patrol_leader = true;
|
||||
}
|
||||
if get("captain", "0") == "1" {
|
||||
m.extra.head = Some(Arc::from(raider::BANNER));
|
||||
}
|
||||
let mut chances = [drops::DEFAULT_DROP_CHANCE; 8];
|
||||
for (i, slot) in drops::SLOTS.iter().enumerate() {
|
||||
if let Some(spec) = c.get(slot) {
|
||||
let s = stack(spec);
|
||||
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
|
||||
let index = match *slot {
|
||||
"mainhand" => {
|
||||
m.extra.main_hand = item;
|
||||
Some(gear::MAIN)
|
||||
}
|
||||
"offhand" => {
|
||||
m.extra.off_hand = item;
|
||||
Some(gear::OFF)
|
||||
}
|
||||
"head" => {
|
||||
m.extra.head = item;
|
||||
Some(gear::HEAD)
|
||||
}
|
||||
"body" if drops::head_is_body(m.species()) => {
|
||||
m.extra.head = item;
|
||||
None
|
||||
}
|
||||
"saddle" => {
|
||||
m.set_saddle(Some(horizon_client::items::ItemStack::new(&s.item, 1)));
|
||||
None
|
||||
}
|
||||
"chest" => {
|
||||
m.extra.chest = item;
|
||||
Some(gear::CHEST)
|
||||
}
|
||||
"legs" => {
|
||||
m.extra.legs = item;
|
||||
Some(gear::LEGS)
|
||||
}
|
||||
"feet" => {
|
||||
m.extra.feet = item;
|
||||
Some(gear::FEET)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(index) = index
|
||||
&& !s.enchantments.is_empty()
|
||||
{
|
||||
let levels = s
|
||||
.enchantments
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(e.as_str()), *l as u32))
|
||||
.collect();
|
||||
gear::set(&mut m, index, levels);
|
||||
}
|
||||
}
|
||||
if let Some(chance) = c.get(format!("chance_{slot}").as_str()) {
|
||||
chances[i] = chance.parse()?;
|
||||
}
|
||||
}
|
||||
m.extra.drop_chances = Some(Box::new(chances));
|
||||
m.random = LegacyRandom::new(rand + k);
|
||||
// The killer, the last player to hurt the victim and the damage source.
|
||||
let killer_id = get("killer", "none");
|
||||
let weapon = c.get("weapon").map(|w| stack(w));
|
||||
let armed = |kind: &str| {
|
||||
let mut e = LootEntity::new(kind);
|
||||
if let Some(w) = &weapon {
|
||||
e.equipment.push(("mainhand".into(), w.clone()));
|
||||
}
|
||||
e
|
||||
};
|
||||
let player = (killer_id == "player" || get("pkill", "0") == "1").then(|| {
|
||||
if killer_id == "player" {
|
||||
armed("minecraft:player")
|
||||
} else {
|
||||
LootEntity::new("minecraft:player")
|
||||
}
|
||||
});
|
||||
let killer = match killer_id.as_str() {
|
||||
"player" => player.clone(),
|
||||
"none" => None,
|
||||
id => {
|
||||
let mut e = armed(id);
|
||||
e.frog_variant = c.get("frog").map(|f| f.to_string());
|
||||
e.powered = get("charged", "0") == "1";
|
||||
Some(e)
|
||||
}
|
||||
};
|
||||
let pkill = get("pkill", if killer_id == "player" { "1" } else { "0" }) == "1";
|
||||
let projectile = |kind: &str| LootEntity {
|
||||
kind: kind.to_owned(),
|
||||
..LootEntity::default()
|
||||
};
|
||||
let damage =
|
||||
|kind: &str, source: Option<LootEntity>, direct: Option<LootEntity>| LootDamage {
|
||||
kind: kind.to_owned(),
|
||||
source,
|
||||
direct,
|
||||
};
|
||||
let source = match get("source", "attack").as_str() {
|
||||
"attack" => match &killer {
|
||||
None => damage("minecraft:generic", None, None),
|
||||
Some(k) if k.kind == "minecraft:player" => {
|
||||
damage("minecraft:player_attack", Some(k.clone()), Some(k.clone()))
|
||||
}
|
||||
Some(k) => damage("minecraft:mob_attack", Some(k.clone()), Some(k.clone())),
|
||||
},
|
||||
"arrow" => damage(
|
||||
"minecraft:arrow",
|
||||
killer.clone(),
|
||||
Some(projectile("minecraft:arrow")),
|
||||
),
|
||||
"fireball" => match &killer {
|
||||
Some(k) => damage(
|
||||
"minecraft:fireball",
|
||||
Some(k.clone()),
|
||||
Some(projectile("minecraft:fireball")),
|
||||
),
|
||||
None => damage(
|
||||
"minecraft:unattributed_fireball",
|
||||
Some(projectile("minecraft:fireball")),
|
||||
Some(projectile("minecraft:fireball")),
|
||||
),
|
||||
},
|
||||
"explosion" => match &killer {
|
||||
Some(k) => damage(
|
||||
"minecraft:player_explosion",
|
||||
Some(k.clone()),
|
||||
Some(k.clone()),
|
||||
),
|
||||
None => damage("minecraft:explosion", None, None),
|
||||
},
|
||||
"lightning" => damage("minecraft:lightning_bolt", None, None),
|
||||
"fire" => damage("minecraft:on_fire", None, None),
|
||||
_ => damage("minecraft:generic", None, None),
|
||||
};
|
||||
let death = drops::Death {
|
||||
loot: &loot,
|
||||
damage: Some(source),
|
||||
player: player.filter(|_| pkill),
|
||||
luck: get("luck", "0").parse()?,
|
||||
vehicle: c.get("vehicle").map(|v| LootEntity::new(v)),
|
||||
mob_drops: true,
|
||||
world_seed,
|
||||
};
|
||||
let (items, _) = drops::die(&mut m, &death, &mut sequences);
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs the JSON-lines scenarios of tools/test_frame_limiter_parity.py through
|
||||
//! the Rust `FramerateLimiter` with the same scripted virtual clock.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
window::{FramerateLimiter, LimiterClock, end_frame},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
/// The scripted clock of the case: every query costs `call` ns, parking
|
||||
/// sleeps the request plus the next overshoot (at least 1 ns), spinning
|
||||
/// costs `spin` ns and interruptions follow their script.
|
||||
struct Script {
|
||||
now: i64,
|
||||
call: i64,
|
||||
spin: i64,
|
||||
overshoot: Vec<i64>,
|
||||
next_overshoot: usize,
|
||||
interrupts: Vec<bool>,
|
||||
next_interrupt: usize,
|
||||
parks: Vec<i64>,
|
||||
spins: u64,
|
||||
queries: u64,
|
||||
}
|
||||
impl LimiterClock for Script {
|
||||
fn nanos(&mut self) -> i64 {
|
||||
self.queries += 1;
|
||||
let t = self.now;
|
||||
self.now = self.now.wrapping_add(self.call);
|
||||
t
|
||||
}
|
||||
fn park(&mut self, nanos: i64) {
|
||||
self.parks.push(nanos);
|
||||
let o = self.overshoot[self.next_overshoot % self.overshoot.len()];
|
||||
self.next_overshoot += 1;
|
||||
self.now = self.now.wrapping_add(nanos.max(0).wrapping_add(o).max(1));
|
||||
}
|
||||
fn spin(&mut self) {
|
||||
self.spins += 1;
|
||||
self.now = self.now.wrapping_add(self.spin);
|
||||
}
|
||||
fn interrupted(&mut self) -> bool {
|
||||
let i = self.interrupts[self.next_interrupt % self.interrupts.len()];
|
||||
self.next_interrupt += 1;
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
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 i = |k: &str| c[k].as_i64().unwrap();
|
||||
let mut clock = Script {
|
||||
now: i("now"),
|
||||
call: i("call"),
|
||||
spin: i("spin"),
|
||||
overshoot: c["overshoot"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_i64().unwrap())
|
||||
.collect(),
|
||||
next_overshoot: 0,
|
||||
interrupts: c["interrupts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_bool().unwrap())
|
||||
.collect(),
|
||||
next_interrupt: 0,
|
||||
parks: Vec::new(),
|
||||
spins: 0,
|
||||
queries: 0,
|
||||
};
|
||||
let mut limiter =
|
||||
FramerateLimiter::with_state(i("last"), i("average"), i("last_limit") as i32);
|
||||
for frame in c["frames"].as_array().unwrap() {
|
||||
clock.now = clock.now.wrapping_add(frame[0].as_i64().unwrap());
|
||||
clock.parks.clear();
|
||||
clock.spins = 0;
|
||||
clock.queries = 0;
|
||||
end_frame(&mut limiter, frame[1].as_i64().unwrap() as i32, &mut clock);
|
||||
let parks: Vec<String> = clock.parks.iter().map(i64::to_string).collect();
|
||||
writeln!(
|
||||
out,
|
||||
"frame {} {} {} {} queries {} spins {} parks [{}]",
|
||||
clock.now,
|
||||
limiter.last_frame_time(),
|
||||
limiter.average_overshoot_ns(),
|
||||
limiter.last_framerate_limit(),
|
||||
clock.queries,
|
||||
clock.spins,
|
||||
parks.join(",")
|
||||
)?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Headless transport for the HUD parity gate: replays each case through
|
||||
//! `horizon_client::hud_layout` and writes the draw calls in vanilla's
|
||||
//! GuiGraphicsExtractor terms. Case kinds: `decorations` (crosshair,
|
||||
//! hotbar, health, mount hearts, experience bar and level) and the
|
||||
//! overlays handled in `kinds`.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
hud_layout::{self, GuiDraw, HealthHud, HealthInputs, HeartKind, HotbarInputs, IDENTITY, Pose},
|
||||
lang,
|
||||
text::Text,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
#[path = "hud_fixture/kinds.rs"]
|
||||
mod kinds;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: hud_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
lang::set(lang::Language::load(&[root], lang::DEFAULT));
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let c: Value = serde_json::from_str(line)?;
|
||||
// State the case reads back besides the draws (e.g. `scopeScale`).
|
||||
let mut state = serde_json::Map::new();
|
||||
let draws = match c["kind"].as_str().unwrap_or("decorations") {
|
||||
"decorations" => decorations(&c, &atlas),
|
||||
kind => kinds::run(kind, &c, &atlas, &mut state),
|
||||
};
|
||||
state.insert("draws".into(), json!(encode(&draws)));
|
||||
writeln!(out, "{}", Value::Object(state))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Style runs of styled text: `[text, rgb|null, bold, italic]` per run of
|
||||
/// equally styled characters, or none for plain text.
|
||||
fn runs(t: &Text) -> Option<Value> {
|
||||
let mut out: Vec<(String, Option<u32>, bool, bool)> = Vec::new();
|
||||
let mut styled = false;
|
||||
for (ch, s) in t.chars() {
|
||||
let key = (s.color, s.is_bold(), s.is_italic());
|
||||
styled |= key != (None, false, false);
|
||||
match out.last_mut() {
|
||||
Some(last) if (last.1, last.2, last.3) == key => last.0.push(ch),
|
||||
_ => out.push((ch.to_string(), key.0, key.1, key.2)),
|
||||
}
|
||||
}
|
||||
styled.then(|| {
|
||||
json!(
|
||||
out.into_iter()
|
||||
.map(|r| json!([r.0, r.1, r.2, r.3]))
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn encode(draws: &[GuiDraw]) -> Vec<Value> {
|
||||
let mut stack: Vec<Pose> = vec![IDENTITY];
|
||||
let mut out = Vec::new();
|
||||
for d in draws {
|
||||
let pose = *stack.last().unwrap();
|
||||
let mut v = match d {
|
||||
GuiDraw::Push(ops) => {
|
||||
let mut m = pose;
|
||||
for op in ops {
|
||||
hud_layout::pose_apply(&mut m, *op);
|
||||
}
|
||||
stack.push(m);
|
||||
continue;
|
||||
}
|
||||
GuiDraw::Pop => {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
GuiDraw::Sprite {
|
||||
sprite,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
region,
|
||||
color,
|
||||
} => {
|
||||
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32})
|
||||
}
|
||||
GuiDraw::AtlasSprite {
|
||||
sprite,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
color,
|
||||
} => {
|
||||
json!({"type": "atlas", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "color": *color as i32})
|
||||
}
|
||||
GuiDraw::Blit {
|
||||
texture,
|
||||
blend,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
uv,
|
||||
src,
|
||||
tex,
|
||||
color,
|
||||
} => json!({
|
||||
"type": "blit", "texture": format!("textures/{texture}.png"), "pipeline": blend.pipeline(),
|
||||
"x": x, "y": y, "w": w, "h": h, "uv": uv, "src": src, "tex": tex, "color": *color as i32}),
|
||||
GuiDraw::Fill {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
color,
|
||||
} => {
|
||||
json!({"type": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": *color as i32})
|
||||
}
|
||||
GuiDraw::Text {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
shadow,
|
||||
} => {
|
||||
let mut v = json!({"type": "text", "text": text.plain(), "x": x, "y": y, "color": *color as i32, "shadow": shadow});
|
||||
if let Some(r) = runs(text) {
|
||||
v["runs"] = r;
|
||||
}
|
||||
v
|
||||
}
|
||||
GuiDraw::Item {
|
||||
slot,
|
||||
x,
|
||||
y,
|
||||
seed,
|
||||
scale,
|
||||
pivot,
|
||||
} => {
|
||||
// translate(pivot) * scale * translate(-pivot) as a 3x2 matrix.
|
||||
let m = [
|
||||
scale[0],
|
||||
0.0,
|
||||
0.0,
|
||||
scale[1],
|
||||
pivot[0] - pivot[0] * scale[0],
|
||||
pivot[1] - pivot[1] * scale[1],
|
||||
];
|
||||
out.push(json!({"type": "item", "slot": slot, "x": x, "y": y, "seed": seed, "matrix": m}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if pose != IDENTITY {
|
||||
v["matrix"] = json!(pose);
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let int = |k: &str, d: i64| c[k].as_i64().unwrap_or(d) as i32;
|
||||
let float = |k: &str, d: f64| c[k].as_f64().unwrap_or(d) as f32;
|
||||
let (w, h) = (int("width", 0), int("height", 0));
|
||||
let mode = c["gameMode"].as_str().unwrap_or("survival");
|
||||
let survival = mode == "survival" || mode == "adventure";
|
||||
let indicator = c["attackIndicator"].as_str().unwrap_or("OFF");
|
||||
let partial = float("partial", 0.0);
|
||||
let effects: Vec<&str> = c["effects"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let has = |e: &str| effects.contains(&e);
|
||||
let mut draws = Vec::new();
|
||||
// Spectators only see it over a spectatable target (none here).
|
||||
if c["crosshair"].as_bool().unwrap_or(false) && mode != "spectator" {
|
||||
let attack = (indicator == "CROSSHAIR").then(|| float("attackScale", 1.0));
|
||||
draws.extend(hud_layout::crosshair(w, h, attack, false));
|
||||
}
|
||||
if mode != "spectator" {
|
||||
let pop = |v: &Value| v.as_f64().map(|p| p as f32 - partial);
|
||||
let slots: [Option<f32>; 9] = std::array::from_fn(|i| pop(&c["slots"][i]));
|
||||
let inputs = HotbarInputs {
|
||||
selected: int("selected", 0),
|
||||
right_handed: c["rightHanded"].as_bool().unwrap_or(true),
|
||||
offhand: !c["offhand"].is_null(),
|
||||
slots,
|
||||
offhand_pop: pop(&c["offhand"]).unwrap_or(0.0),
|
||||
attack: (indicator == "HOTBAR").then(|| float("attackScale", 1.0)),
|
||||
};
|
||||
draws.extend(hud_layout::hotbar(w, h, &inputs));
|
||||
}
|
||||
let vehicle_hearts = if c.get("vehicleMaxHealth").is_some() {
|
||||
hud_layout::vehicle_max_hearts(float("vehicleMaxHealth", 0.0))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if survival {
|
||||
let millis = 1_000_000_000i64;
|
||||
let mut hud = HealthHud {
|
||||
last_health: int("lastHealth", 0),
|
||||
display_health: int("displayHealth", 0),
|
||||
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
|
||||
health_blink_time: i64::from(int("healthBlinkTime", 0)),
|
||||
};
|
||||
let kind = if has("minecraft:poison") {
|
||||
HeartKind::Poisoned
|
||||
} else if has("minecraft:wither") {
|
||||
HeartKind::Withered
|
||||
} else if c["frozen"].as_bool().unwrap_or(false) {
|
||||
HeartKind::Frozen
|
||||
} else {
|
||||
HeartKind::Normal
|
||||
};
|
||||
let inputs = HealthInputs {
|
||||
tick: int("tick", 0),
|
||||
millis,
|
||||
health: float("health", 20.0),
|
||||
max_health: float("maxHealth", 20.0),
|
||||
absorption: float("absorption", 0.0),
|
||||
armor: int("armor", 0),
|
||||
food: int("food", 20),
|
||||
saturation: float("saturation", 5.0),
|
||||
kind,
|
||||
regeneration: has("minecraft:regeneration"),
|
||||
hunger: has("minecraft:hunger"),
|
||||
hardcore: c["hardcore"].as_bool().unwrap_or(false),
|
||||
damage_cooldown: int("damageCooldown", 0) > 0,
|
||||
air: int("air", 300),
|
||||
max_air: int("maxAir", 300),
|
||||
under_water: c["underWater"].as_bool().unwrap_or(false),
|
||||
vehicle_hearts,
|
||||
};
|
||||
draws.extend(hud.layout(&inputs, w, h));
|
||||
}
|
||||
if vehicle_hearts > 0 {
|
||||
draws.extend(hud_layout::vehicle_health(
|
||||
w,
|
||||
h,
|
||||
vehicle_hearts,
|
||||
float("vehicleHealth", 0.0),
|
||||
));
|
||||
}
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
|
||||
let level = if survival { int("xpLevel", 0) } else { 0 };
|
||||
draws.extend(hud_layout::experience(
|
||||
w,
|
||||
h,
|
||||
float("xpProgress", 0.0),
|
||||
level,
|
||||
c["experienceBar"].as_bool().unwrap_or(false),
|
||||
&width,
|
||||
));
|
||||
draws
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! The overlay case kinds of the HUD parity fixture.
|
||||
use horizon_client::{
|
||||
brewing,
|
||||
chat::{Chat, ChatOptions},
|
||||
component,
|
||||
hud::HudAtlas,
|
||||
hud_layout::{self, GuiDraw},
|
||||
hud_overlays::{
|
||||
self, BossBar, CameraOverlays, EffectIcon, HeldName, ItemDecorations, Titles, ToolHighlight,
|
||||
},
|
||||
scoreboard::{NumberFormat, RenderType, Scoreboard},
|
||||
text::{self, Text},
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
fn int(c: &Value, k: &str, d: i64) -> i32 {
|
||||
c[k].as_i64().unwrap_or(d) as i32
|
||||
}
|
||||
fn float(c: &Value, k: &str, d: f64) -> f32 {
|
||||
c[k].as_f64().unwrap_or(d) as f32
|
||||
}
|
||||
/// A text component in its JSON form.
|
||||
fn comp(v: &Value) -> Text {
|
||||
component::to_text(v, &mut |s| Text::literal(s))
|
||||
}
|
||||
/// `Options.getBackgroundColor(0.0F)` from the case's text background options.
|
||||
fn background(c: &Value) -> u32 {
|
||||
hud_layout::background_color(
|
||||
c["chatOnly"].as_bool().unwrap_or(true),
|
||||
c["textOpacity"].as_f64().unwrap_or(0.5),
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
kind: &str,
|
||||
c: &Value,
|
||||
atlas: &HudAtlas,
|
||||
state: &mut serde_json::Map<String, Value>,
|
||||
) -> Vec<GuiDraw> {
|
||||
let (w, h) = (int(c, "width", 0), int(c, "height", 0));
|
||||
match kind {
|
||||
"effects" => effects(c, w),
|
||||
"title" => title(c, w, h, atlas),
|
||||
"selected" => selected(c, w, h, atlas),
|
||||
"boss" => boss(c, w, h, atlas),
|
||||
"jump" => hud_overlays::jump_bar(w, h, int(c, "cooldown", 0), float(c, "jumpScale", 0.0)),
|
||||
"sidebar" => sidebar(c, w, h, atlas),
|
||||
"decor" => decor(c, atlas),
|
||||
"overlays" => overlays(c, w, h, state),
|
||||
"chat" => chat(c, h, atlas),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn effects(c: &Value, w: i32) -> Vec<GuiDraw> {
|
||||
let icons: Vec<EffectIcon> = c["active"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let id = e["id"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches("minecraft:");
|
||||
let def = brewing::effect(id).expect("effect");
|
||||
EffectIcon {
|
||||
id: id.to_owned(),
|
||||
beneficial: def.category == brewing::Category::Beneficial,
|
||||
color: def.color as i32,
|
||||
ambient: e["ambient"].as_bool().unwrap_or(false),
|
||||
duration: e["duration"].as_i64().unwrap_or(0) as i32,
|
||||
show_icon: e["icon"].as_bool().unwrap_or(true),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
hud_overlays::effects(w, c["demo"].as_bool().unwrap_or(false), &icons)
|
||||
}
|
||||
|
||||
/// Replays `Hud.setTitle` / `setSubtitle` / `setTimes` / `clearTitles` /
|
||||
/// `resetTitleTimes` / `setOverlayMessage` and ticks, then extracts the
|
||||
/// action bar and the title.
|
||||
fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let mut t = Titles::default();
|
||||
for op in c["ops"].as_array().into_iter().flatten() {
|
||||
let n = |i: usize| op[i].as_i64().unwrap_or(0) as i32;
|
||||
match op[0].as_str().unwrap_or("") {
|
||||
"title" => t.set_title(comp(&op[1])),
|
||||
"subtitle" => t.set_subtitle(comp(&op[1])),
|
||||
"times" => t.set_times(n(1), n(2), n(3)),
|
||||
"clear" => t.clear(),
|
||||
"reset" => t.reset_times(),
|
||||
"overlay" => t.set_overlay(comp(&op[1]), op[2].as_bool().unwrap_or(false)),
|
||||
"tick" => {
|
||||
for _ in 0..n(1) {
|
||||
t.tick();
|
||||
}
|
||||
}
|
||||
other => panic!("unknown title op {other}"),
|
||||
}
|
||||
}
|
||||
let partial = float(c, "partial", 0.0);
|
||||
let bg = background(c);
|
||||
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
|
||||
out.extend(t.title(w, h, partial, &atlas.font, bg));
|
||||
out
|
||||
}
|
||||
|
||||
/// Ticks `toolHighlightTimer` through the selected stacks, then extracts the name.
|
||||
fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let mut highlight = ToolHighlight::default();
|
||||
let time = c["displayTime"].as_f64().unwrap_or(1.0);
|
||||
for step in c["steps"].as_array().into_iter().flatten() {
|
||||
let held = (!step.is_null()).then(|| {
|
||||
let custom = &step["custom"];
|
||||
HeldName {
|
||||
item: step["item"].as_str().unwrap_or("").to_owned(),
|
||||
hover_name: if custom.is_null() {
|
||||
Text::translate(step["key"].as_str().unwrap_or(""), &[])
|
||||
} else {
|
||||
comp(custom)
|
||||
},
|
||||
rarity_color: text::named_color(step["rarity"].as_str().unwrap_or("white"))
|
||||
.unwrap_or(0xFFFFFF),
|
||||
custom_name: !custom.is_null(),
|
||||
}
|
||||
});
|
||||
highlight.tick(held, time);
|
||||
}
|
||||
let mode = c["gameMode"].as_str().unwrap_or("survival");
|
||||
let can_hurt = mode == "survival" || mode == "adventure";
|
||||
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
|
||||
}
|
||||
|
||||
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let bars: Vec<BossBar> = c["bars"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|b| BossBar {
|
||||
name: comp(&b["name"]),
|
||||
progress: b["progress"].as_f64().unwrap_or(0.0) as f32,
|
||||
color: b["color"].as_u64().unwrap_or(0) as usize,
|
||||
overlay: b["overlay"].as_u64().unwrap_or(0) as usize,
|
||||
})
|
||||
.collect();
|
||||
hud_overlays::boss_overlay(w, h, &bars, &atlas.font)
|
||||
}
|
||||
|
||||
/// A `NumberFormat` from the case: null, `{type: blank}`, `{type: fixed,
|
||||
/// value}` or `{type: styled, style}`.
|
||||
fn number_format(v: &Value) -> Option<NumberFormat> {
|
||||
match v["type"].as_str()? {
|
||||
"blank" => Some(NumberFormat::Blank),
|
||||
"fixed" => Some(NumberFormat::Fixed(v["value"].clone())),
|
||||
"styled" => Some(NumberFormat::Styled(v["style"].clone())),
|
||||
other => panic!("unknown number format {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sidebar(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let mut board = Scoreboard::default();
|
||||
let o = &c["objective"];
|
||||
board.add_objective(
|
||||
"sidebar",
|
||||
"dummy",
|
||||
o["display"].clone(),
|
||||
RenderType::Integer,
|
||||
false,
|
||||
number_format(&o["format"]),
|
||||
);
|
||||
for s in c["scores"].as_array().into_iter().flatten() {
|
||||
let owner = s["owner"].as_str().unwrap_or("");
|
||||
let score = board.score_mut(owner, "sidebar");
|
||||
score.value = s["value"].as_i64().unwrap_or(0) as i32;
|
||||
score.display = (!s["display"].is_null()).then(|| s["display"].clone());
|
||||
score.number_format = number_format(&s["format"]);
|
||||
}
|
||||
for t in c["teams"].as_array().into_iter().flatten() {
|
||||
let name = t["name"].as_str().unwrap_or("");
|
||||
let team = board.add_team(name);
|
||||
team.prefix = t["prefix"].clone();
|
||||
team.suffix = t["suffix"].clone();
|
||||
team.color = t["color"].as_str().map(str::to_owned);
|
||||
for m in t["members"].as_array().into_iter().flatten() {
|
||||
board.add_to_team(m.as_str().unwrap_or(""), name);
|
||||
}
|
||||
}
|
||||
let opacity = c["textOpacity"].as_f64().unwrap_or(0.5);
|
||||
let chat_only = c["chatOnly"].as_bool().unwrap_or(true);
|
||||
let objective = board.objective("sidebar").expect("objective").clone();
|
||||
hud_overlays::sidebar(
|
||||
w,
|
||||
h,
|
||||
&board,
|
||||
&objective,
|
||||
&atlas.font,
|
||||
hud_layout::background_color(chat_only, opacity, 0.3),
|
||||
hud_layout::background_color(chat_only, opacity, 0.4),
|
||||
)
|
||||
}
|
||||
|
||||
/// `itemDecorations` for a stack with a count, damage and an item cooldown
|
||||
/// `elapsed` ticks into `duration` (ItemCooldowns.getCooldownPercent).
|
||||
fn decor(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let count = int(c, "count", 1);
|
||||
if count <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let cd = &c["cooldown"];
|
||||
let cooldown = if cd.is_null() {
|
||||
0.0
|
||||
} else {
|
||||
let (duration, elapsed) = (int(cd, "duration", 0), int(cd, "elapsed", 0));
|
||||
if duration <= elapsed {
|
||||
0.0 // ItemCooldowns.tick dropped it
|
||||
} else {
|
||||
let remaining = duration as f32 - (elapsed as f32 + float(c, "partial", 0.0));
|
||||
(remaining / duration as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
};
|
||||
let max_damage = if c["unbreakable"].as_bool().unwrap_or(false) {
|
||||
0
|
||||
} else {
|
||||
int(c, "maxDamage", 0)
|
||||
};
|
||||
let d = ItemDecorations {
|
||||
count: count as u32,
|
||||
count_text: c["countText"].as_str().map(str::to_owned),
|
||||
bar: hud_overlays::damage_bar(int(c, "damage", 0), max_damage),
|
||||
cooldown,
|
||||
};
|
||||
hud_overlays::item_decorations(int(c, "x", 0), int(c, "y", 0), &d, &atlas.font)
|
||||
}
|
||||
|
||||
/// `Hud.extractCameraOverlays` then `extractSleepOverlay`; the border is
|
||||
/// static, so only its warning blocks matter.
|
||||
fn overlays(c: &Value, w: i32, h: i32, state: &mut serde_json::Map<String, Value>) -> Vec<GuiDraw> {
|
||||
let partial = float(c, "partial", 0.0);
|
||||
let warning = match &c["border"] {
|
||||
Value::Null => 0.0,
|
||||
b => {
|
||||
// WorldBorder.getDistanceToBorder with the static extent's clamped box.
|
||||
let max = 29_999_984.0;
|
||||
let half = b["size"].as_f64().unwrap_or(0.0) / 2.0;
|
||||
let (cx, cz) = (
|
||||
b["centerX"].as_f64().unwrap_or(0.0),
|
||||
b["centerZ"].as_f64().unwrap_or(0.0),
|
||||
);
|
||||
let (min_x, max_x) = ((cx - half).clamp(-max, max), (cx + half).clamp(-max, max));
|
||||
let (min_z, max_z) = ((cz - half).clamp(-max, max), (cz + half).clamp(-max, max));
|
||||
let (x, z) = (
|
||||
f64::from(float(c, "camX", 0.0)),
|
||||
f64::from(float(c, "camZ", 0.0)),
|
||||
);
|
||||
let distance = (x - min_x).min(max_x - x).min(z - min_z).min(max_z - z);
|
||||
hud_overlays::border_warning(
|
||||
distance as f32,
|
||||
int(b, "warningBlocks", 5),
|
||||
int(b, "warningTime", 15),
|
||||
0.0,
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
};
|
||||
let ticks_frozen = int(c, "ticksFrozen", 0);
|
||||
let inputs = CameraOverlays {
|
||||
vignette: c["vignette"]
|
||||
.as_bool()
|
||||
.unwrap_or(false)
|
||||
.then(|| (float(c, "vignetteBrightness", 1.0), warning)),
|
||||
first_person: c["firstPerson"].as_bool().unwrap_or(false),
|
||||
scoping: c["scoping"].as_bool().unwrap_or(false),
|
||||
game_time_delta: partial,
|
||||
equipment: match c["head"].as_str() {
|
||||
Some("carved_pumpkin") => vec!["misc/pumpkinblur".to_owned()],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
ticks_frozen,
|
||||
percent_frozen: ticks_frozen.min(140) as f32 / 140.0,
|
||||
partial,
|
||||
portal: [
|
||||
c["portal"][0].as_f64().unwrap_or(0.0) as f32,
|
||||
c["portal"][1].as_f64().unwrap_or(0.0) as f32,
|
||||
],
|
||||
nausea: float(c, "nausea", 0.0),
|
||||
screen_effect_scale: float(c, "screenEffectScale", 1.0),
|
||||
};
|
||||
let mut scope = float(c, "scopeScale", 0.5);
|
||||
let mut out = hud_overlays::camera_overlays(w, h, &inputs, &mut scope);
|
||||
out.extend(hud_overlays::sleep_overlay(w, h, int(c, "sleepTimer", 0)));
|
||||
state.insert("scopeScale".into(), serde_json::json!(scope));
|
||||
out
|
||||
}
|
||||
|
||||
/// Messages added at their ticks (`Hud.tickCount`), then the chat in
|
||||
/// background mode at tick `now`.
|
||||
fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
let option = |k: &str| c[k].as_f64().unwrap_or(0.0);
|
||||
let mut chat = Chat::default();
|
||||
chat.options = ChatOptions {
|
||||
opacity: option("chatOpacity"),
|
||||
background_opacity: option("textOpacity"),
|
||||
scale: option("chatScale"),
|
||||
width: option("chatWidth"),
|
||||
height_focused: option("chatHeightFocused"),
|
||||
height_unfocused: option("chatHeightUnfocused"),
|
||||
line_spacing: option("chatLineSpacing"),
|
||||
};
|
||||
for m in c["messages"].as_array().into_iter().flatten() {
|
||||
// GuiMessageTag indicator colours: systemSinglePlayer and chatError.
|
||||
let indicator = match m["tag"].as_str() {
|
||||
Some("system") => Some(13684944),
|
||||
Some("error") => Some(16733525),
|
||||
_ => None,
|
||||
};
|
||||
chat.add(
|
||||
atlas,
|
||||
comp(&m["text"]),
|
||||
indicator,
|
||||
m["at"].as_u64().unwrap_or(0),
|
||||
false,
|
||||
);
|
||||
}
|
||||
chat.layout(h, c["now"].as_u64().unwrap_or(0), false)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Horizon's LAN world discovery for tools/test_lan_parity.py: the cases of
|
||||
//! VanillaLanOracle (announcements sent, datagrams heard, list resets) run
|
||||
//! through `lan::discovery`, printing the same lines.
|
||||
use horizon_client::lan::discovery::{
|
||||
LanGame, add_server, parse_address, parse_motd, ping_string_text,
|
||||
};
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len() / 2)
|
||||
.map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let cases = std::fs::read_to_string(&args[1]).unwrap();
|
||||
let mut out = String::new();
|
||||
let mut games: Vec<LanGame> = Vec::new();
|
||||
for line in cases.lines() {
|
||||
let f: Vec<&str> = line.split('\t').collect();
|
||||
match f[0] {
|
||||
"ping" => out += &format!("ping {}\n", hex(ping_string_text(f[1], f[2]).as_bytes())),
|
||||
"heard" => {
|
||||
let received = String::from_utf8_lossy(&unhex(f[2])).into_owned();
|
||||
out += &format!(
|
||||
"heard motd={} address={}\n",
|
||||
parse_motd(&received),
|
||||
parse_address(&received).unwrap_or_else(|| "null".into())
|
||||
);
|
||||
if add_server(&mut games, &received, f[1]) {
|
||||
out += "list";
|
||||
for g in &games {
|
||||
out += &format!(" [{} @ {}]", g.motd, g.address);
|
||||
}
|
||||
out += "\n";
|
||||
}
|
||||
}
|
||||
"reset" => games.clear(),
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
}
|
||||
std::fs::write(&args[2], out).unwrap();
|
||||
}
|
||||
@@ -35,10 +35,12 @@ fn main() -> horizon_client::Result<()> {
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
@@ -50,6 +52,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
menu.set_slot(&env, i, parse_stack(slot.as_str().unwrap_or("-"))?);
|
||||
}
|
||||
menu.carried = parse_stack(case["carried"].as_str().unwrap_or("-"))?;
|
||||
menu.settle(&env);
|
||||
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
|
||||
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
|
||||
}
|
||||
@@ -58,6 +61,23 @@ fn main() -> horizon_client::Result<()> {
|
||||
for op in case["ops"].as_array().ok_or("ops")? {
|
||||
let slot = op[0].as_i64().unwrap_or(0) as i32;
|
||||
let button = op[1].as_i64().unwrap_or(0) as i32;
|
||||
match op[2].as_str().unwrap_or_default() {
|
||||
// ServerboundContainerButtonClickPacket: clickMenuButton.
|
||||
"BUTTON" => {
|
||||
menu.button(&env, slot);
|
||||
out += &format!("{}\n", state(&menu));
|
||||
continue;
|
||||
}
|
||||
// ServerboundContainerSlotStateChangedPacket.
|
||||
"SLOT_STATE" => {
|
||||
if (0..9).contains(&slot) {
|
||||
menu.set_slot_state(slot as usize, button == 1);
|
||||
}
|
||||
out += &format!("{}\n", state(&menu));
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
|
||||
match menu.click(&env, slot, button, kind) {
|
||||
Ok(()) => out += &format!("{}\n", state(&menu)),
|
||||
|
||||
@@ -0,0 +1,868 @@
|
||||
//! Replays `tools/test_mob_ai_parity.py` scenarios on Horizon's mob AI and
|
||||
//! prints each mob's AI-visible state after every tick in the format of
|
||||
//! `VanillaMobAiOracle`.
|
||||
//!
|
||||
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, MIN_Y},
|
||||
collisions,
|
||||
containers::{BlockEntity, CreakingHeart},
|
||||
items::Items,
|
||||
light::{LightTables, light_chunk},
|
||||
mobs::{
|
||||
Damage, MobType, creaking,
|
||||
creaking_heart::{self, HeartEvent, HeartHost},
|
||||
},
|
||||
mobs::{
|
||||
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
|
||||
control::Operation, spawning::LocalDifficulty,
|
||||
},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
|
||||
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
struct MobSpec {
|
||||
kind: String,
|
||||
pos: [f64; 3],
|
||||
yaw: f32,
|
||||
seed: i64,
|
||||
baby: bool,
|
||||
flags: Vec<String>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Scenario {
|
||||
name: String,
|
||||
ticks: i32,
|
||||
time: i64,
|
||||
rain: bool,
|
||||
thunder: bool,
|
||||
origin: [i32; 2],
|
||||
blocks: Vec<Vec<String>>,
|
||||
player: Option<[f64; 3]>,
|
||||
creative: bool,
|
||||
/// The tick from which the player faces another yaw.
|
||||
turn: Option<(i32, f32)>,
|
||||
/// Creaking hearts placed when the scenario starts.
|
||||
hearts: Vec<Vec<String>>,
|
||||
/// `ServerLevel.isSpawningMonsters`.
|
||||
spawning: bool,
|
||||
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
|
||||
hurts: Vec<(i32, usize)>,
|
||||
hand: Option<String>,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> Vec<Scenario> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Scenario> = None;
|
||||
for line in text.lines() {
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
if f.is_empty() || f[0].starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let s = cur.get_or_insert_with(Scenario::default);
|
||||
match f[0] {
|
||||
"scenario" => {
|
||||
s.name = f[1].to_owned();
|
||||
s.time = 6000;
|
||||
for kv in &f[2..] {
|
||||
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
match k {
|
||||
"ticks" => s.ticks = v.parse().unwrap(),
|
||||
"time" => s.time = v.parse().unwrap(),
|
||||
"rain" => s.rain = true,
|
||||
"thunder" => s.thunder = true,
|
||||
"levelseed" => {}
|
||||
"spawning" => s.spawning = true,
|
||||
_ => panic!("unknown scenario option {kv}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
"origin" => s.origin = [f[1].parse().unwrap(), f[2].parse().unwrap()],
|
||||
"fill" | "block" => s.blocks.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
f[2].parse().unwrap(),
|
||||
f[3].parse().unwrap(),
|
||||
]);
|
||||
for opt in &f[4..] {
|
||||
if *opt == "creative" {
|
||||
s.creative = true;
|
||||
} else if let Some(t) = opt.strip_prefix("turn=") {
|
||||
let (tick, yaw) = t.split_once(',').unwrap();
|
||||
s.turn = Some((tick.parse().unwrap(), yaw.parse().unwrap()));
|
||||
} else if let Some(item) = opt.strip_prefix("hand=") {
|
||||
s.hand = Some(item.to_owned());
|
||||
} else {
|
||||
panic!("unknown player option {opt}");
|
||||
}
|
||||
}
|
||||
}
|
||||
"mob" => s.mobs.push(MobSpec {
|
||||
kind: f[1].to_owned(),
|
||||
pos: [
|
||||
f[2].parse().unwrap(),
|
||||
f[3].parse().unwrap(),
|
||||
f[4].parse().unwrap(),
|
||||
],
|
||||
yaw: f[5].parse().unwrap(),
|
||||
seed: f[6].parse().unwrap(),
|
||||
baby: f[7..].contains(&"baby"),
|
||||
flags: f[7..]
|
||||
.iter()
|
||||
.filter(|x| **x != "baby")
|
||||
.map(|x| (*x).to_owned())
|
||||
.collect(),
|
||||
}),
|
||||
"end" => out.push(cur.take().unwrap()),
|
||||
other => panic!("unknown scenario line {other}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `minecraft:name[key=value,...]`
|
||||
fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
|
||||
let (name, props) = match text.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (text, ""),
|
||||
};
|
||||
let mut properties = BTreeMap::new();
|
||||
for kv in props.split(',').filter(|s| !s.is_empty()) {
|
||||
let (k, v) = kv.split_once('=').expect("property");
|
||||
properties.insert(k.to_owned(), v.to_owned());
|
||||
}
|
||||
registry
|
||||
.resolve(&BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("{text}: {e}"))
|
||||
}
|
||||
|
||||
fn d(v: f64) -> String {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
fn f(v: f32) -> String {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
fn who(w: Option<Who>) -> String {
|
||||
match w {
|
||||
None => "none".into(),
|
||||
Some(Who::Player) => "player".into(),
|
||||
Some(Who::Mob(id)) => format!("e{id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vanilla's goal class for a Horizon goal (most share the name).
|
||||
fn goal_name(m: &Mob, name: &str) -> String {
|
||||
let vanilla = match name {
|
||||
"Float" => "FloatGoal",
|
||||
"Stroll" => "WaterAvoidingRandomStrollGoal",
|
||||
"RandomStroll" => "RandomStrollGoal",
|
||||
"RandomSwimming" => "RandomSwimmingGoal",
|
||||
"LookAtPlayer" => "LookAtPlayerGoal",
|
||||
"RandomLookAround" => "RandomLookAroundGoal",
|
||||
"HurtByTarget" => "HurtByTargetGoal",
|
||||
"NearestTarget" => "NearestAttackableTargetGoal",
|
||||
"Panic" => "PanicGoal",
|
||||
"Breed" => "BreedGoal",
|
||||
"Tempt" => "TemptGoal",
|
||||
"FollowParent" => "FollowParentGoal",
|
||||
"MeleeAttack" => match m.species() {
|
||||
Species::Zombie | Species::Husk | Species::ZombieVillager => "ZombieAttackGoal",
|
||||
_ => "MeleeAttackGoal",
|
||||
},
|
||||
"MoveThroughVillage" => "MoveThroughVillageGoal",
|
||||
"RestrictSun" => "RestrictSunGoal",
|
||||
"FleeSun" => "FleeSunGoal",
|
||||
"AvoidEntity" => "AvoidEntityGoal",
|
||||
"BowAttack" => "RangedBowAttackGoal",
|
||||
"Swell" => "SwellGoal",
|
||||
"LeapAtTarget" => "LeapAtTargetGoal",
|
||||
"EatBlock" => "EatBlockGoal",
|
||||
"MoveTowardsRestriction" => "MoveTowardsRestrictionGoal",
|
||||
"TradeWithPlayer" => "TradeWithPlayerGoal",
|
||||
"LookAtTradingPlayer" => "LookAtTradingPlayerGoal",
|
||||
"WanderToPosition" => "WanderToPositionGoal",
|
||||
"DrinkItem" => "UseItemGoal",
|
||||
"BreathAir" => "BreathAirGoal",
|
||||
"TryFindWater" => "TryFindWaterGoal",
|
||||
"RangedAttack" => "RangedAttackGoal",
|
||||
"MoveTowardsTarget" => "MoveTowardsTargetGoal",
|
||||
"DrownedGoToWater" => "DrownedGoToWaterGoal",
|
||||
"TakeBlock" => "EndermanTakeBlockGoal",
|
||||
"LeaveBlock" => "EndermanLeaveBlockGoal",
|
||||
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
|
||||
"LookForPlayer" => "EndermanLookForPlayerGoal",
|
||||
"HealRaiders" => "NearestHealableRaiderTargetGoal",
|
||||
other => return other.to_owned(),
|
||||
};
|
||||
vanilla.to_owned()
|
||||
}
|
||||
|
||||
fn goals(m: &Mob, names: Vec<&'static str>) -> String {
|
||||
if names.is_empty() {
|
||||
return "-".into();
|
||||
}
|
||||
names
|
||||
.iter()
|
||||
.map(|n| goal_name(m, n))
|
||||
.collect::<Vec<_>>()
|
||||
.join("|")
|
||||
}
|
||||
|
||||
fn seen(m: &Mob) -> Seen {
|
||||
Seen {
|
||||
id: m.id,
|
||||
species: m.species(),
|
||||
position: m.position,
|
||||
bounds: m.bounds(),
|
||||
eye_y: m.eye_y(),
|
||||
alive: m.alive(),
|
||||
baby: m.is_baby(),
|
||||
in_love: if m.species() == Species::Villager {
|
||||
horizon_client::mobs::villager::can_breed(m)
|
||||
} else {
|
||||
m.extra.in_love > 0
|
||||
},
|
||||
tame: m.extra.tame,
|
||||
hostile: m.hostile(),
|
||||
target: m.ai.target,
|
||||
health: m.health,
|
||||
variant: m.extra.variant,
|
||||
color: m.extra.color,
|
||||
job: (m.species() == Species::Villager)
|
||||
.then_some(m.extra.home)
|
||||
.flatten(),
|
||||
bed: (m.species() == Species::Villager)
|
||||
.then_some(m.extra.bed)
|
||||
.flatten(),
|
||||
yaw: m.yaw,
|
||||
velocity: m.velocity,
|
||||
leash: m.extra.leash,
|
||||
caravan: horizon_client::mobs::caravan::head_of(m),
|
||||
delay: m.extra.ints[5],
|
||||
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
|
||||
active_raid: horizon_client::mobs::raider::has_active_raid(m),
|
||||
player_rep: 0,
|
||||
pushable: m.pushable(),
|
||||
category: m.category(),
|
||||
potential_job: horizon_client::mobs::villager_brain::potential_job_site(m),
|
||||
meeting: (m.species() == Species::Villager)
|
||||
.then_some(m.extra.meeting)
|
||||
.flatten(),
|
||||
profession: m.extra.variant2,
|
||||
xp: horizon_client::mobs::villager::xp(m),
|
||||
stats: [
|
||||
f64::from(m.attrs.max_health),
|
||||
m.extra.vector[0],
|
||||
m.attrs.speed,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe's mobs as a creaking heart sees them: creakings it creates take
|
||||
/// the n-th seed from 1000, as the oracle's do.
|
||||
struct Host<'a> {
|
||||
mobs: &'a mut Vec<Mob>,
|
||||
added: Vec<Mob>,
|
||||
types: &'a MobTypes,
|
||||
created: &'a mut i64,
|
||||
spawning: bool,
|
||||
}
|
||||
impl HeartHost for Host<'_> {
|
||||
fn mob(&self, id: u32) -> Option<&Mob> {
|
||||
self.mobs
|
||||
.iter()
|
||||
.chain(self.added.iter())
|
||||
.find(|m| m.id == id)
|
||||
}
|
||||
fn mob_mut(&mut self, id: u32) -> Option<&mut Mob> {
|
||||
self.mobs
|
||||
.iter_mut()
|
||||
.chain(self.added.iter_mut())
|
||||
.find(|m| m.id == id)
|
||||
}
|
||||
fn mob_for_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
|
||||
self.mob(uuid[3] as u32)
|
||||
.filter(|m| !m.removed)
|
||||
.map(|m| m.id)
|
||||
}
|
||||
fn uuid_for(&mut self, id: u32) -> [i32; 4] {
|
||||
[0, 0, 0, id as i32]
|
||||
}
|
||||
fn creaking_type(&self) -> Option<Arc<MobType>> {
|
||||
self.types
|
||||
.types
|
||||
.get("minecraft:creaking")
|
||||
.map(|t| Arc::new(t.clone()))
|
||||
}
|
||||
fn create_mob(&mut self, kind: Arc<MobType>, at: Vec3) -> Mob {
|
||||
let seed = 1000 + *self.created;
|
||||
*self.created += 1;
|
||||
let mut m = Mob::new(200_000 + seed as u32, kind, at, 0.0);
|
||||
m.random = LegacyRandom::new(seed);
|
||||
horizon_client::mobs::monster_brain::init(&mut m, seed);
|
||||
m
|
||||
}
|
||||
fn add_mob(&mut self, m: Mob) {
|
||||
self.added.push(m);
|
||||
}
|
||||
fn spawning_monsters(&self) -> bool {
|
||||
self.spawning
|
||||
}
|
||||
}
|
||||
/// The heart's state, block entity and the level random, as the oracle
|
||||
/// prints them.
|
||||
fn print_heart(
|
||||
out: &mut String,
|
||||
t: i32,
|
||||
registry: &BlockRegistry,
|
||||
world: &World,
|
||||
at: [i32; 3],
|
||||
heart: &CreakingHeart,
|
||||
lr: &LegacyRandom,
|
||||
) {
|
||||
let state = registry
|
||||
.state(world.block(at[0], at[1], at[2]))
|
||||
.and_then(|s| s.properties.get("creaking_heart_state").cloned())
|
||||
.unwrap_or_default();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{t} heart {},{},{}\t{state}\t{} {} {} {} {}\tlrng {:x}",
|
||||
at[0],
|
||||
at[1],
|
||||
at[2],
|
||||
heart.ticker,
|
||||
heart.emitter,
|
||||
heart.output_signal,
|
||||
heart.ticks_existed,
|
||||
if heart.creaking.is_some() {
|
||||
"linked"
|
||||
} else {
|
||||
"none"
|
||||
},
|
||||
lr.state()
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether an event hurts the scenario's player (survival, normal difficulty,
|
||||
/// no armour, made hurtable again every tick by the oracle).
|
||||
fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
let Some(p) = player.filter(|p| !p.creative) else {
|
||||
return false;
|
||||
};
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
|
||||
MobEvent::Explode(at, radius) => {
|
||||
let d = Vec3::new(at[0], at[1], at[2])
|
||||
.distance_squared(p.position)
|
||||
.sqrt();
|
||||
let r = 2.0 * f64::from(*radius);
|
||||
d / r <= 1.0 && d != 0.0
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn print(out: &mut String, t: i32, i: usize, m: &Mob) {
|
||||
let _ = write!(out, "{t} {i}");
|
||||
if m.removed {
|
||||
// Entity.RemovalReason: the end of the death animation, else a discard.
|
||||
let reason = if m.alive() { "DISCARDED" } else { "KILLED" };
|
||||
let _ = writeln!(out, " removed {reason}");
|
||||
return;
|
||||
}
|
||||
let p = m.position;
|
||||
let v = m.velocity;
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tp {},{},{}\tv {},{},{}",
|
||||
d(p.x),
|
||||
d(p.y),
|
||||
d(p.z),
|
||||
d(v.x),
|
||||
d(v.y),
|
||||
d(v.z)
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tr {},{},{},{}",
|
||||
f(m.yaw),
|
||||
f(m.head_pitch),
|
||||
f(m.head_yaw),
|
||||
f(m.body_yaw)
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tg {}{}\thp {}",
|
||||
u8::from(m.on_ground),
|
||||
u8::from(m.horizontal_collision),
|
||||
f(m.health)
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tgoals {}\ttargets {}\ttarget {}",
|
||||
goals(m, m.ai.goals.running_names()),
|
||||
goals(m, m.ai.targets.running_names()),
|
||||
who(m.ai.target)
|
||||
);
|
||||
let _ = write!(out, "\tnav ");
|
||||
match &m.ai.nav.path {
|
||||
None => out.push_str("none"),
|
||||
Some(path) => {
|
||||
let _ = write!(
|
||||
out,
|
||||
"{},{},{} {}/{}{}",
|
||||
path.target[0],
|
||||
path.target[1],
|
||||
path.target[2],
|
||||
path.next,
|
||||
path.nodes.len(),
|
||||
if path.next >= path.nodes.len() {
|
||||
" done"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
let mv = &m.ai.mov;
|
||||
let op = match mv.operation {
|
||||
Operation::Wait => "WAIT",
|
||||
Operation::MoveTo => "MOVE_TO",
|
||||
Operation::Strafe => "STRAFE",
|
||||
Operation::Jumping => "JUMPING",
|
||||
};
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tmove {op} {},{},{} {}",
|
||||
d(mv.wanted[0]),
|
||||
d(mv.wanted[1]),
|
||||
d(mv.wanted[2]),
|
||||
d(mv.speed_modifier)
|
||||
);
|
||||
let lk = &m.ai.look;
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tlook {} {},{},{}",
|
||||
lk.cooldown,
|
||||
d(lk.wanted[0]),
|
||||
d(lk.wanted[1]),
|
||||
d(lk.wanted[2])
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\tin {},{},{},{}",
|
||||
f(m.ai.xxa),
|
||||
f(m.ai.zza),
|
||||
f(m.ai.speed),
|
||||
u8::from(m.ai.jumping)
|
||||
);
|
||||
let _ = write!(out, "\trng {:x}", m.random.state());
|
||||
let brain = horizon_client::mobs::villager_brain::describe(m)
|
||||
.or_else(|| horizon_client::mobs::monster_brain::describe(m))
|
||||
.or_else(|| horizon_client::mobs::animal_brain::describe(m));
|
||||
if let Some((acts, mem)) = brain {
|
||||
let _ = write!(out, "\tact {acts}\tmem {mem}");
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.len() != 3 {
|
||||
return Err("usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT".into());
|
||||
}
|
||||
let root = Path::new(&args[0]);
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let types = MobTypes::load(root)?;
|
||||
let biomes = Biomes::load(root)?;
|
||||
let items = Items::load(root, registry, &facts)?;
|
||||
let tables = LightTables::load(root, registry, &facts)?;
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let air = registry.default_state("minecraft:air").ok_or("no air")?;
|
||||
let layers = [
|
||||
state(registry, "minecraft:bedrock"),
|
||||
state(registry, "minecraft:dirt"),
|
||||
state(registry, "minecraft:dirt"),
|
||||
state(registry, "minecraft:grass_block"),
|
||||
];
|
||||
let plains = biomes.id("minecraft:plains");
|
||||
let mut out = String::new();
|
||||
for s in parse(&fs::read_to_string(&args[1])?) {
|
||||
let _ = writeln!(out, "== {}", s.name);
|
||||
let mut world = World::new(air, 0);
|
||||
let (ocx, ocz) = (s.origin[0] >> 4, s.origin[1] >> 4);
|
||||
const R: i32 = 4;
|
||||
for cz in ocz - R..=ocz + R {
|
||||
for cx in ocx - R..=ocx + R {
|
||||
let mut chunk = Chunk::empty(cx, cz);
|
||||
for qy in 0..HEIGHT / 4 {
|
||||
for qz in 0..4 {
|
||||
for qx in 0..4 {
|
||||
chunk.set_biome_cell(qx, qy, qz, plains);
|
||||
}
|
||||
}
|
||||
}
|
||||
world.chunks.insert((cx, cz), Arc::new(chunk));
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
for (y, layer) in layers.iter().enumerate() {
|
||||
world.set(cx * 16 + x, MIN_Y + y as i32, cz * 16 + z, *layer)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for b in &s.blocks {
|
||||
let n = |i: usize| b[i].parse::<i32>().unwrap();
|
||||
if b[0] == "block" {
|
||||
world.set(n(1), n(2), n(3), state(registry, &b[4]))?;
|
||||
} else {
|
||||
let st = state(registry, &b[7]);
|
||||
for x in n(1).min(n(4))..=n(1).max(n(4)) {
|
||||
for y in n(2).min(n(5))..=n(2).max(n(5)) {
|
||||
for z in n(3).min(n(6))..=n(3).max(n(6)) {
|
||||
world.set(x, y, z, st)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut hearts: Vec<([i32; 3], CreakingHeart)> = Vec::new();
|
||||
for h in &s.hearts {
|
||||
let at = [
|
||||
h[1].parse::<i32>().unwrap(),
|
||||
h[2].parse::<i32>().unwrap(),
|
||||
h[3].parse::<i32>().unwrap(),
|
||||
];
|
||||
world.set(at[0], at[1], at[2], state(registry, &h[4]))?;
|
||||
hearts.push((at, CreakingHeart::default()));
|
||||
}
|
||||
let mut created = 0_i64;
|
||||
let keys: Vec<(i32, i32)> = world.chunks.keys().copied().collect();
|
||||
let lit: Vec<((i32, i32), Chunk)> = keys
|
||||
.iter()
|
||||
.map(|&(x, z)| {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
world
|
||||
.chunks
|
||||
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
|
||||
.map(|c| &**c)
|
||||
})
|
||||
});
|
||||
let mut c = (*world.chunks[&(x, z)]).clone();
|
||||
c.sections = light_chunk(®ion, &tables, true);
|
||||
c.lit = true;
|
||||
((x, z), c)
|
||||
})
|
||||
.collect();
|
||||
for (k, c) in lit {
|
||||
world.chunks.insert(k, Arc::new(c));
|
||||
}
|
||||
world.day_time = s.time;
|
||||
world.game_time = 1000;
|
||||
let mut mobs: Vec<Mob> = Vec::new();
|
||||
for (i, spec) in s.mobs.iter().enumerate() {
|
||||
let kind = types
|
||||
.types
|
||||
.get(&spec.kind)
|
||||
.ok_or_else(|| format!("unknown mob {}", spec.kind))?;
|
||||
let mut m = Mob::new(
|
||||
100_000 + i as u32,
|
||||
Arc::new(kind.clone()),
|
||||
Vec3::new(spec.pos[0], spec.pos[1], spec.pos[2]),
|
||||
spec.yaw,
|
||||
);
|
||||
if spec.baby {
|
||||
m.extra.age = -24000;
|
||||
}
|
||||
for flag in &spec.flags {
|
||||
match flag.as_str() {
|
||||
"persistent" => m.persistent = true,
|
||||
"noai" => m.no_ai = true,
|
||||
"inlove" => m.extra.in_love = 600,
|
||||
"tame" => m.extra.tame = true,
|
||||
x if x.starts_with("age=") => m.extra.age = x[4..].parse().unwrap(),
|
||||
x if x.starts_with("strength=") => m.extra.variant2 = x[9..].parse().unwrap(),
|
||||
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
|
||||
x if x.starts_with("leashto=") => {
|
||||
let n: u32 = x[8..].parse().unwrap();
|
||||
m.extra.leash = Some(horizon_client::mobs::leash::Holder::Mob(100_000 + n));
|
||||
}
|
||||
other => panic!("unknown mob flag {other}"),
|
||||
}
|
||||
}
|
||||
m.random = LegacyRandom::new(spec.seed);
|
||||
if horizon_client::mobs::monster_brain::has_brain(m.species()) {
|
||||
// The oracle delays the sensors again with the seeded random
|
||||
// and seeds RunOne's shuffles with the mob's seed.
|
||||
horizon_client::mobs::monster_brain::init(&mut m, spec.seed);
|
||||
}
|
||||
if m.species() == Species::Villager {
|
||||
// The oracle seeds the random after the brain is built, then
|
||||
// delays the sensors again and seeds the gates' shuffles.
|
||||
horizon_client::mobs::villager_brain::init(
|
||||
&mut m,
|
||||
world.game_time,
|
||||
s.time,
|
||||
spec.seed,
|
||||
);
|
||||
}
|
||||
// The same for the sniffer's and the happy ghast's brains.
|
||||
if horizon_client::mobs::animal_brain::kind_of(&m).is_some() {
|
||||
horizon_client::mobs::animal_brain::init(&mut m, spec.seed);
|
||||
}
|
||||
mobs.push(m);
|
||||
}
|
||||
let mut player = s.player.map(|p| {
|
||||
let position = Vec3::new(p[0], p[1], p[2]);
|
||||
PlayerView {
|
||||
position,
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
|
||||
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
|
||||
),
|
||||
eye: [p[0], p[1] + f64::from(1.62_f32), p[2]],
|
||||
alive: true,
|
||||
creative: s.creative,
|
||||
sneaking: false,
|
||||
look: [0.0, 0.0, 1.0],
|
||||
main_hand: s.hand.as_deref().map(Into::into),
|
||||
off_hand: None,
|
||||
head: None,
|
||||
armor_cover: 0.0,
|
||||
spectator: false,
|
||||
gold_armor: false,
|
||||
hurt_by: None,
|
||||
last_hurt: None,
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
hero: false,
|
||||
uuid: [0, 0x1234, 0, 0x5678],
|
||||
allies: Default::default(),
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, 0, i, m);
|
||||
}
|
||||
// The MobWorld every step sees, over a snapshot of the mobs.
|
||||
macro_rules! mob_world {
|
||||
($snapshot:expr) => {
|
||||
MobWorld {
|
||||
teams: Default::default(),
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: player.clone(),
|
||||
others: $snapshot,
|
||||
daylight: (s.time.rem_euclid(24000)) < 12000,
|
||||
game_time: world.game_time,
|
||||
day_time: world.day_time,
|
||||
raining: s.rain,
|
||||
thundering: s.thunder,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
// FlatLevelSource.getSeaLevel.
|
||||
sea_level: -63,
|
||||
}
|
||||
};
|
||||
}
|
||||
for t in 1..=s.ticks {
|
||||
world.game_time += 1;
|
||||
let mut heart_events_later: Vec<HeartEvent> = Vec::new();
|
||||
// Creaking.tick reads the heart's block entity.
|
||||
for (at, h) in &hearts {
|
||||
world.block_entities.insert(
|
||||
(at[0], at[1], at[2]),
|
||||
BlockEntity::CreakingHeart(Box::new(h.clone())),
|
||||
);
|
||||
}
|
||||
if let (Some(p), Some((tick, yaw))) = (player.as_mut(), s.turn)
|
||||
&& t >= tick
|
||||
{
|
||||
// Entity.calculateViewVector(0, yaw).
|
||||
let r = -yaw * horizon_core::geometry::trig::DEG_TO_RAD;
|
||||
let (cos, sin) = (
|
||||
horizon_core::geometry::trig::cos(f64::from(r)),
|
||||
horizon_core::geometry::trig::sin(f64::from(r)),
|
||||
);
|
||||
p.look = [f64::from(sin), -0.0, f64::from(cos)];
|
||||
p.yaw = yaw;
|
||||
}
|
||||
let mut snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
|
||||
let w = mob_world!(&snapshot);
|
||||
let mut events = Vec::new();
|
||||
let mut heart_events = Vec::new();
|
||||
let mut added = Vec::new();
|
||||
for &(tick, i) in &s.hurts {
|
||||
if tick != t || i >= mobs.len() || mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
let at = player
|
||||
.as_ref()
|
||||
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
|
||||
mobs[i].damage(1.0, Damage::player(at));
|
||||
// Creaking.hurtServer: the heart's reaction, then the sway.
|
||||
if let Some((home, by_player)) = creaking::take_hurt(&mut mobs[i], &w) {
|
||||
if by_player && let Some((_, h)) = hearts.iter_mut().find(|(at, _)| *at == home)
|
||||
{
|
||||
let mut host = Host {
|
||||
mobs: &mut mobs,
|
||||
added: Vec::new(),
|
||||
types: &types,
|
||||
created: &mut created,
|
||||
spawning: s.spawning,
|
||||
};
|
||||
creaking_heart::creaking_hurt(
|
||||
h,
|
||||
home,
|
||||
&w,
|
||||
&mut scratch,
|
||||
&mut host,
|
||||
&mut heart_events,
|
||||
);
|
||||
added.append(&mut host.added);
|
||||
}
|
||||
for e in heart_events.drain(..) {
|
||||
match e {
|
||||
HeartEvent::Sound(name, _, volume, pitch) => {
|
||||
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch))
|
||||
}
|
||||
HeartEvent::SetBlock(..) => heart_events_later.push(e),
|
||||
HeartEvent::Mob(ev) => events.push(ev),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
creaking::sway(&mut mobs[i], &mut events);
|
||||
}
|
||||
events.append(&mut mobs[i].outbox);
|
||||
}
|
||||
#[allow(unused_mut)] // filled only with the rng-trace feature
|
||||
let mut traces: Vec<String> = Vec::new();
|
||||
let mut pushes = Vec::new();
|
||||
for i in 0..mobs.len() {
|
||||
if mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
let w = mob_world!(&snapshot);
|
||||
let m = &mut mobs[i];
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let from = events.len();
|
||||
#[cfg(feature = "rng-trace")]
|
||||
horizon_core::random::trace::set(true);
|
||||
let alive = m.tick(&w, &mut scratch, &mut events);
|
||||
#[cfg(feature = "rng-trace")]
|
||||
{
|
||||
horizon_core::random::trace::set(false);
|
||||
for line in horizon_core::random::trace::take() {
|
||||
traces.push(format!("{t} trace {i} {line}"));
|
||||
}
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
if !alive {
|
||||
m.removed = true;
|
||||
}
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
// ServerLevel ticks entities one after another: the ones
|
||||
// after this mob see it as it is now.
|
||||
if let Some(e) = snapshot.iter_mut().find(|e| e.id == mobs[i].id) {
|
||||
*e = seen(&mobs[i]);
|
||||
e.alive &= !mobs[i].removed;
|
||||
}
|
||||
}
|
||||
for m in &mut mobs {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
}
|
||||
let w = mob_world!(&snapshot);
|
||||
// ServerLevel.tickBlockEntities: the hearts (none ticks once uprooted).
|
||||
heart_events.append(&mut heart_events_later);
|
||||
for (at, h) in &mut hearts {
|
||||
let uprooted = registry
|
||||
.state(w.world.block(at[0], at[1], at[2]))
|
||||
.and_then(|s| s.properties.get("creaking_heart_state").cloned())
|
||||
.is_some_and(|v| v == "uprooted");
|
||||
if uprooted {
|
||||
continue;
|
||||
}
|
||||
let mut host = Host {
|
||||
mobs: &mut mobs,
|
||||
added: Vec::new(),
|
||||
types: &types,
|
||||
created: &mut created,
|
||||
spawning: s.spawning,
|
||||
};
|
||||
creaking_heart::server_tick(h, *at, &w, &mut scratch, &mut host, &mut heart_events);
|
||||
added.append(&mut host.added);
|
||||
}
|
||||
drop(w);
|
||||
mobs.append(&mut added);
|
||||
for e in &heart_events {
|
||||
match e {
|
||||
HeartEvent::SetBlock(at, st) => world.set(at[0], at[1], at[2], *st)?,
|
||||
HeartEvent::Sound(name, _, volume, pitch) => {
|
||||
events.push(MobEvent::Sound(name, [0.0; 3], *volume, *pitch))
|
||||
}
|
||||
HeartEvent::Mob(ev) => events.push(ev.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, t, i, m);
|
||||
}
|
||||
for e in &events {
|
||||
if let MobEvent::Sound(name, _, volume, pitch) = e {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{t} sound minecraft:{name} {} {}",
|
||||
f(*volume),
|
||||
f(*pitch)
|
||||
);
|
||||
} else if let MobEvent::SoundId(name, _, volume, pitch) = e {
|
||||
let _ = writeln!(out, "{t} sound {name} {} {}", f(*volume), f(*pitch));
|
||||
} else if player_hurt(player.as_ref(), e) {
|
||||
// The oracle's survival player, healed and made hurtable every tick,
|
||||
// takes the damage (LivingEntity.playHurtSound).
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{t} sound minecraft:entity.player.hurt 3f800000 3f800000"
|
||||
);
|
||||
}
|
||||
}
|
||||
for (at, h) in &hearts {
|
||||
print_heart(&mut out, t, registry, &world, *at, h, &scratch);
|
||||
}
|
||||
for line in traces {
|
||||
let _ = writeln!(out, "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Replays `tools/test_music_parity.py` scripts through Horizon's
|
||||
//! `MusicManager` over its `SoundEngine`: `music_fixture ROOT INPUT OUTPUT`.
|
||||
#[path = "audio_common/mod.rs"]
|
||||
mod common;
|
||||
use common::{Fake, describe, f, flush};
|
||||
use horizon_client::music::{Music, MusicFrequency, MusicManager};
|
||||
use horizon_client::sound_engine::{Bound, MUSIC, SoundEngine, TickContext};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use std::io::Write;
|
||||
|
||||
fn music(spec: &str) -> Option<Music> {
|
||||
if spec == "-" {
|
||||
return None;
|
||||
}
|
||||
let p: Vec<&str> = spec.split(',').collect();
|
||||
Some(Music::new(
|
||||
&horizon_client::sound_events::normalize(p[0]),
|
||||
p[1].parse().expect("min"),
|
||||
p[2].parse().expect("max"),
|
||||
p[3] == "1",
|
||||
))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
|
||||
engine.listening = true;
|
||||
let mut backend = Fake::default();
|
||||
let mut manager = MusicManager::new(0);
|
||||
let (mut situation, mut volume) = (None::<Music>, 1.0f32);
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
writeln!(out, "{line}").expect("write");
|
||||
let a: Vec<&str> = line.split(' ').collect();
|
||||
match a[0] {
|
||||
"start" => {
|
||||
if let Some(id) = manager.current_instance() {
|
||||
engine.release(id);
|
||||
}
|
||||
manager = MusicManager::new(a[1].parse().expect("seed"));
|
||||
manager.frequency = MusicFrequency::parse(a[2]);
|
||||
engine.stop_all(&mut backend);
|
||||
}
|
||||
"situation" => {
|
||||
situation = music(a[1]);
|
||||
volume = f(a[2]);
|
||||
}
|
||||
"tick" => {
|
||||
let mut sounds = Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
};
|
||||
manager.tick(situation.as_ref(), volume, a[1] == "1", &mut sounds);
|
||||
engine.tick(a[2] == "1", &TickContext::default(), &mut backend);
|
||||
}
|
||||
"finish" => {
|
||||
if let Some(c) = manager.current_instance().and_then(|id| engine.channel(id)) {
|
||||
horizon_client::sound_engine::Backend::stop(&mut backend, c);
|
||||
}
|
||||
}
|
||||
"stop" => manager.stop_playing(
|
||||
situation.as_ref(),
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
),
|
||||
"stopmusic" => {
|
||||
let m = music(a[1]).expect("music");
|
||||
manager.stop_playing_music(
|
||||
&m,
|
||||
situation.as_ref(),
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
}
|
||||
"freq" => manager.set_frequency(MusicFrequency::parse(a[1]), situation.as_ref()),
|
||||
"toast" => manager.show_now_playing_toast_if_needed(),
|
||||
"volume" => {
|
||||
let source: usize = a[1].parse().expect("source");
|
||||
engine.source_volumes[source] = f(a[2]);
|
||||
engine.refresh_category_volume(source, &mut backend);
|
||||
}
|
||||
"stopall" => engine.stop_all(&mut backend),
|
||||
_ => panic!("{line}"),
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" current={} delay={} gain={:08x} toastShown={} musicGain={:08x}",
|
||||
manager.current(&engine).unwrap_or("-"),
|
||||
manager.next_song_delay(),
|
||||
manager.current_gain().to_bits(),
|
||||
manager.toast_shown(),
|
||||
engine.category_gain(MUSIC).to_bits()
|
||||
)
|
||||
.expect("write");
|
||||
for show in std::mem::take(&mut manager.toast_requests) {
|
||||
writeln!(out, " toast {}", if show { "show" } else { "hide" }).expect("write");
|
||||
}
|
||||
if let Some(id) = manager.current_instance() {
|
||||
writeln!(out, "{}", describe(&engine, &backend, "music", id)).expect("write");
|
||||
}
|
||||
flush(&mut engine, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
//! Natural spawning decisions for differential tests against vanilla
|
||||
//! (tools/test_natural_spawn_parity.py): reads the box-world cases of
|
||||
//! `VanillaNaturalSpawnOracle` and prints what `mobs::spawn_selection` does in
|
||||
//! the same order and format.
|
||||
//!
|
||||
//! The oracle runs `finalizeSpawn` with a fixed random (nextInt 0, nextFloat
|
||||
//! 0.75); `neutral_box` is the box that leaves a mob with: polar bears,
|
||||
//! ocelots and rabbits after the first of their group are babies (chance
|
||||
//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon
|
||||
//! are small, slimes and magma cubes keep size 1, tropical fish school.
|
||||
//!
|
||||
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::{Biomes, SpawnEntry},
|
||||
blocks::BlockFacts,
|
||||
collisions::{self, LookupCollisions, no_collision_empty},
|
||||
items::Items,
|
||||
mobs::{
|
||||
MobTypes, Species,
|
||||
data::MobData,
|
||||
spawn_rules::{Dimension, SpawnLevel},
|
||||
spawn_selection::{
|
||||
self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost,
|
||||
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
spawn_box,
|
||||
},
|
||||
species::{self, Baby},
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Aabb,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Bx([i32; 6]);
|
||||
impl Bx {
|
||||
fn parse(s: &str) -> Self {
|
||||
let v: Vec<i32> = s.split(',').map(|n| n.parse().unwrap_or(0)).collect();
|
||||
Self([v[0], v[1], v[2], v[3], v[4], v[5]])
|
||||
}
|
||||
fn has(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
let b = self.0;
|
||||
x >= b[0] && x <= b[3] && y >= b[1] && y <= b[4] && z >= b[2] && z <= b[5]
|
||||
}
|
||||
}
|
||||
struct Start {
|
||||
id: String,
|
||||
pieces: Vec<Bx>,
|
||||
/// The start's bounding box (pieces encapsulated, inflated by 12 for
|
||||
/// terrain adaptation).
|
||||
bounds: Bx,
|
||||
}
|
||||
|
||||
struct Shared<'a> {
|
||||
facts: &'a BlockFacts,
|
||||
data: &'a MobData,
|
||||
block_tags: &'a HashMap<String, HashSet<String>>,
|
||||
types: &'a MobTypes,
|
||||
biomes: &'a Biomes,
|
||||
structures: &'a StructureSpawns,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
air: BlockStateId,
|
||||
}
|
||||
struct World<'a> {
|
||||
s: &'a Shared<'a>,
|
||||
blocks: Vec<(Bx, BlockStateId)>,
|
||||
lights: Vec<(Bx, i32, i32)>,
|
||||
biomes: Vec<(Bx, String)>,
|
||||
biome: String,
|
||||
sky: i32,
|
||||
light: i32,
|
||||
darken: i32,
|
||||
thunder: bool,
|
||||
sea: i32,
|
||||
seed: i64,
|
||||
peaceful: bool,
|
||||
dimension: Dimension,
|
||||
dimension_id: String,
|
||||
slime: f32,
|
||||
starts: Vec<Start>,
|
||||
players: Vec<([f64; 3], String)>,
|
||||
respawn: Option<[i32; 3]>,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
spawn_mobs: bool,
|
||||
/// Boxes of living entities (case entities, then spawned mobs).
|
||||
entities: RefCell<Vec<Aabb>>,
|
||||
}
|
||||
impl World<'_> {
|
||||
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
|
||||
self.biomes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(b, _)| b.has(x, y, z))
|
||||
.map_or(&self.biome, |(_, n)| n)
|
||||
}
|
||||
fn biome_info(&self, x: i32, y: i32, z: i32) -> Option<&horizon_client::biome::BiomeInfo> {
|
||||
let i = *self.s.biomes.index.get(self.biome_at(x, y, z))?;
|
||||
Some(self.s.biomes.get(i))
|
||||
}
|
||||
fn lights_at(&self, x: i32, y: i32, z: i32) -> (i32, i32) {
|
||||
self.lights
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(b, ..)| b.has(x, y, z))
|
||||
.map_or((self.sky, self.light), |(_, s, b)| (*s, *b))
|
||||
}
|
||||
}
|
||||
impl SpawnLevel for World<'_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
self.blocks
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(b, _)| b.has(x, y, z))
|
||||
.map_or(self.s.air, |(_, s)| *s)
|
||||
}
|
||||
fn facts(&self) -> &BlockFacts {
|
||||
self.s.facts
|
||||
}
|
||||
fn mob_data(&self) -> &MobData {
|
||||
self.s.data
|
||||
}
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.s
|
||||
.block_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(block))
|
||||
}
|
||||
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
|
||||
self.s
|
||||
.data
|
||||
.biome_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(self.biome_at(x, y, z)))
|
||||
}
|
||||
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.lights_at(x, y, z).0
|
||||
}
|
||||
fn block_light(&self, x: i32, y: i32, z: i32) -> i32 {
|
||||
self.lights_at(x, y, z).1
|
||||
}
|
||||
fn sky_darken(&self) -> i32 {
|
||||
self.darken
|
||||
}
|
||||
fn thundering(&self) -> bool {
|
||||
self.thunder
|
||||
}
|
||||
fn sea_level(&self) -> i32 {
|
||||
self.sea
|
||||
}
|
||||
fn world_surface(&self, x: i32, z: i32) -> i32 {
|
||||
self.heightmap(Heightmap::WorldSurface, x, z)
|
||||
}
|
||||
fn peaceful(&self) -> bool {
|
||||
self.peaceful
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
|
||||
self.slime
|
||||
}
|
||||
fn world_seed(&self) -> i64 {
|
||||
self.seed
|
||||
}
|
||||
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
|
||||
self.players.iter().any(|(p, mode)| {
|
||||
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
(mode == "survival" || mode == "adventure") && d < distance * distance
|
||||
})
|
||||
}
|
||||
}
|
||||
impl NaturalLevel for World<'_> {
|
||||
fn min_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
-64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
if self.dimension == Dimension::Overworld {
|
||||
319
|
||||
} else {
|
||||
255
|
||||
}
|
||||
}
|
||||
fn ambient_light(&self) -> f32 {
|
||||
match self.dimension {
|
||||
Dimension::Overworld => 0.0,
|
||||
Dimension::Nether => 0.1,
|
||||
Dimension::End => 0.25,
|
||||
}
|
||||
}
|
||||
fn has_ceiling(&self) -> bool {
|
||||
self.dimension == Dimension::Nether
|
||||
}
|
||||
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
|
||||
let t = self.s.types.types.get(entity)?;
|
||||
Some((
|
||||
t.species,
|
||||
t.width as f32,
|
||||
t.height as f32,
|
||||
Category::from_name(&t.category),
|
||||
))
|
||||
}
|
||||
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
|
||||
self.biome_info(x, y, z).map(|b| &b.spawns)
|
||||
}
|
||||
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
|
||||
self.biome_info(x, y, z)?.spawn_costs.get(entity).copied()
|
||||
}
|
||||
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
|
||||
self.biome_info(x, y, z)
|
||||
.map_or(0.1, |b| b.creature_probability)
|
||||
}
|
||||
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let mut out: Vec<StructureAt> = Vec::new();
|
||||
for start in &self.starts {
|
||||
let b = start.bounds.0;
|
||||
// Referenced: the start's box reaches the chunk's columns.
|
||||
if b[3] < cx * 16 || b[0] > cx * 16 + 15 || b[5] < cz * 16 || b[2] > cz * 16 + 15 {
|
||||
continue;
|
||||
}
|
||||
let in_piece = start.pieces.iter().any(|p| p.has(x, y, z));
|
||||
let in_bounds = start.bounds.has(x, y, z);
|
||||
match out.iter_mut().find(|s| s.id == start.id) {
|
||||
Some(s) => {
|
||||
s.in_piece |= in_piece;
|
||||
s.in_bounds |= in_bounds;
|
||||
}
|
||||
None => out.push(StructureAt {
|
||||
id: start.id.clone(),
|
||||
in_piece,
|
||||
in_bounds,
|
||||
}),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
self.s.structures
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let mut best: Option<f64> = None;
|
||||
for (p, mode) in &self.players {
|
||||
if mode == "spectator" {
|
||||
continue;
|
||||
}
|
||||
let d = [p[0] - x, p[1] - y, p[2] - z];
|
||||
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if best.is_none_or(|b| d < b) {
|
||||
best = Some(d);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
self.respawn
|
||||
}
|
||||
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
|
||||
!self.unloaded.contains(&(cx, cz))
|
||||
}
|
||||
fn no_collision(&self, bounds: Aabb) -> bool {
|
||||
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
|
||||
let mut world = LookupCollisions {
|
||||
block: &lookup,
|
||||
binding: self.s.collision,
|
||||
};
|
||||
no_collision_empty(&mut world, bounds)
|
||||
}
|
||||
fn unobstructed(&self, bounds: Aabb) -> bool {
|
||||
!self.entities.borrow().iter().any(|b| b.intersects(bounds))
|
||||
}
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
self.spawn_mobs
|
||||
}
|
||||
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
|
||||
(self.min_y()..=self.max_y())
|
||||
.rev()
|
||||
.find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z))
|
||||
.map_or(self.min_y(), |y| y + 1)
|
||||
}
|
||||
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
self.s.data.state(self.state(x, y, z)).flags & horizon_client::mobs::data::LAND != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
|
||||
fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb {
|
||||
let t = &l.s.types.types[r.entity];
|
||||
let (mut w, mut h) = (t.width as f32, t.height as f32);
|
||||
let baby = match r.species {
|
||||
Species::PolarBear | Species::Ocelot | Species::Rabbit => r.group_index > 0,
|
||||
Species::Fox | Species::Axolotl => r.group_index >= 2,
|
||||
_ => false,
|
||||
};
|
||||
if baby {
|
||||
match species::def(r.species).baby {
|
||||
Baby::Abs(bw, bh, _) => (w, h) = (bw as f32, bh as f32),
|
||||
Baby::Scale(s, _) => (w, h) = (w * s as f32, h * s as f32),
|
||||
Baby::None => {}
|
||||
}
|
||||
}
|
||||
match r.species {
|
||||
Species::SulfurCube => (w, h) = (w * 2.0, h * 2.0),
|
||||
Species::Salmon => (w, h) = (w * 0.5, h * 0.5),
|
||||
_ => {}
|
||||
}
|
||||
let [x, y, z] = r.position;
|
||||
spawn_box(x, y, z, w, h)
|
||||
}
|
||||
|
||||
fn parse_state(spec: &str) -> BlockState {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
BlockState {
|
||||
name: name.to_owned(),
|
||||
properties: props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
fn bits(v: f64) -> String {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
/// The yaw as the oracle prints it: a cube mob grown by `finalizeSpawn`
|
||||
/// (sulfur cubes, size 2 under the fixed random) takes its constructor's
|
||||
/// head rotation from the entity's unseeded random.
|
||||
fn yaw_text(entity: &str, v: f32) -> String {
|
||||
if entity == "minecraft:sulfur_cube" {
|
||||
"head".to_owned()
|
||||
} else {
|
||||
format!("{:x}", v.to_bits())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(
|
||||
args.get(1)
|
||||
.ok_or("usage: natural_spawn_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let biomes = Biomes::load(root)?;
|
||||
let structures = StructureSpawns::load(root);
|
||||
let collision = collisions::load(root, registry)?;
|
||||
let shared = Shared {
|
||||
facts: &facts,
|
||||
data: &data,
|
||||
block_tags: &items.block_tags,
|
||||
types: &types,
|
||||
biomes: &biomes,
|
||||
structures: &structures,
|
||||
collision: &collision,
|
||||
air: facts.air,
|
||||
};
|
||||
let mut adaptation: HashMap<String, bool> = HashMap::new();
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut c = BTreeMap::new();
|
||||
let mut name = "";
|
||||
for token in raw.split_whitespace() {
|
||||
match token.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
c.insert(k, v);
|
||||
}
|
||||
None => name = token,
|
||||
}
|
||||
}
|
||||
let get =
|
||||
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
|
||||
let indexed = |prefix: &str| -> Vec<&str> {
|
||||
(0..)
|
||||
.map_while(|i| c.get(format!("{prefix}{i}").as_str()).copied())
|
||||
.collect()
|
||||
};
|
||||
let mut blocks = Vec::new();
|
||||
for k in indexed("k") {
|
||||
let (b, s) = k.split_once('/').ok_or("bad block box")?;
|
||||
blocks.push((Bx::parse(b), registry.resolve(&parse_state(s))?));
|
||||
}
|
||||
let lights = indexed("l")
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let (b, v) = l.split_once('/')?;
|
||||
let (s, k) = v.split_once(',')?;
|
||||
Some((Bx::parse(b), s.parse().ok()?, k.parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let biome_boxes = indexed("b")
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let (b, n) = l.split_once('/')?;
|
||||
Some((Bx::parse(b), n.to_owned()))
|
||||
})
|
||||
.collect();
|
||||
let mut starts = Vec::new();
|
||||
for s in indexed("s") {
|
||||
let (id, pieces) = s.split_once('/').ok_or("bad structure")?;
|
||||
let pieces: Vec<Bx> = pieces.split(';').map(Bx::parse).collect();
|
||||
let mut b = pieces[0].0;
|
||||
for p in &pieces[1..] {
|
||||
for i in 0..3 {
|
||||
b[i] = b[i].min(p.0[i]);
|
||||
b[i + 3] = b[i + 3].max(p.0[i + 3]);
|
||||
}
|
||||
}
|
||||
let adapts = *adaptation.entry(id.to_owned()).or_insert_with(|| {
|
||||
let path = root.join(format!(
|
||||
"data/minecraft/worldgen/structure/{}.json",
|
||||
id.trim_start_matches("minecraft:")
|
||||
));
|
||||
fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||
.and_then(|v| v["terrain_adaptation"].as_str().map(|a| a != "none"))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if adapts {
|
||||
for i in 0..3 {
|
||||
b[i] -= 12;
|
||||
b[i + 3] += 12;
|
||||
}
|
||||
}
|
||||
starts.push(Start {
|
||||
id: id.to_owned(),
|
||||
pieces,
|
||||
bounds: Bx(b),
|
||||
});
|
||||
}
|
||||
let players = indexed("p")
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let mut parts = p.split('/');
|
||||
let v: Vec<f64> = parts
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0.0))
|
||||
.collect();
|
||||
(
|
||||
[v[0], v[1], v[2]],
|
||||
parts.next().unwrap_or("survival").to_owned(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let dimension_id = get("dim", "minecraft:overworld");
|
||||
let dimension = match dimension_id.as_str() {
|
||||
"minecraft:the_nether" => Dimension::Nether,
|
||||
"minecraft:the_end" => Dimension::End,
|
||||
_ => Dimension::Overworld,
|
||||
};
|
||||
let respawn = {
|
||||
let r = get("respawn", "0,-1000,0/minecraft:overworld");
|
||||
let (p, d) = r.split_once('/').ok_or("bad respawn")?;
|
||||
let v: Vec<i32> = p.split(',').map(|n| n.parse().unwrap_or(0)).collect();
|
||||
(d == dimension_id).then_some([v[0], v[1], v[2]])
|
||||
};
|
||||
let unloaded = c
|
||||
.get("unloaded")
|
||||
.map(|u| {
|
||||
u.split(';')
|
||||
.filter_map(|p| {
|
||||
let (x, z) = p.split_once(',')?;
|
||||
Some((x.parse().ok()?, z.parse().ok()?))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Case entities: (type, position, persistent).
|
||||
let mut case_entities = Vec::new();
|
||||
for e in indexed("e") {
|
||||
let parts: Vec<&str> = e.split('/').collect();
|
||||
let v: Vec<f64> = parts[1]
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0.0))
|
||||
.collect();
|
||||
// EntityType.create makes no monsters in peaceful.
|
||||
if get("difficulty", "normal") == "peaceful"
|
||||
&& types.types.get(parts[0]).is_some_and(|t| {
|
||||
!horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
case_entities.push((
|
||||
parts[0].to_owned(),
|
||||
[v[0], v[1], v[2]],
|
||||
parts.get(2) == Some(&"p"),
|
||||
));
|
||||
}
|
||||
let world = World {
|
||||
s: &shared,
|
||||
blocks,
|
||||
lights,
|
||||
biomes: biome_boxes,
|
||||
biome: get("biome", "minecraft:plains"),
|
||||
sky: get("sky", "15").parse()?,
|
||||
light: get("light", "0").parse()?,
|
||||
darken: get("darken", "0").parse()?,
|
||||
thunder: get("thunder", "0") == "1",
|
||||
sea: get("sea", "63").parse()?,
|
||||
seed: get("wseed", "0").parse()?,
|
||||
peaceful: get("difficulty", "normal") == "peaceful",
|
||||
dimension,
|
||||
dimension_id: dimension_id.clone(),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
starts,
|
||||
players,
|
||||
respawn,
|
||||
unloaded,
|
||||
spawn_mobs: get("rules", "1") != "0",
|
||||
entities: RefCell::new(
|
||||
case_entities
|
||||
.iter()
|
||||
.filter_map(|(t, p, _)| {
|
||||
let t = types.types.get(t)?;
|
||||
Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
let _ = &world.dimension_id;
|
||||
let chunk = {
|
||||
let v: Vec<i32> = get("chunk", "0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
(v[0], v[1])
|
||||
};
|
||||
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
let mut spawn = |l: &World, r: &SpawnRequest| -> Option<Spawned> {
|
||||
let bounds = neutral_box(l, r);
|
||||
l.entities.borrow_mut().push(bounds);
|
||||
Some(Spawned {
|
||||
bounds,
|
||||
school: true,
|
||||
})
|
||||
};
|
||||
let mode = get("mode", "chunk");
|
||||
if mode == "gen" {
|
||||
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
|
||||
// The level random of the spawn predicates: no creature's predicate
|
||||
// draws from it (the oracle's answers fixed values).
|
||||
let mut rules = LegacyRandom::new(0);
|
||||
let mut spawns = Vec::new();
|
||||
let source = [chunk.0 * 16, world.max_y(), chunk.1 * 16];
|
||||
spawn_selection::spawn_mobs_for_chunk_generation(
|
||||
&world,
|
||||
source,
|
||||
chunk,
|
||||
&mut gen_random,
|
||||
&mut rules,
|
||||
&mut spawn,
|
||||
&mut |e| match e {
|
||||
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
|
||||
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
|
||||
"spawn {t} {} {} {} {}",
|
||||
bits(p[0]),
|
||||
bits(p[1]),
|
||||
bits(p[2]),
|
||||
yaw_text(&t, yaw)
|
||||
)),
|
||||
},
|
||||
);
|
||||
log.extend(spawns);
|
||||
log.push(format!(
|
||||
"next {}",
|
||||
gen_random.next_int_bound(1000).unwrap_or(0)
|
||||
));
|
||||
} else {
|
||||
let counted: Vec<Counted> = case_entities
|
||||
.iter()
|
||||
.filter_map(|(t, p, persistent)| {
|
||||
let kind = types.types.get(t)?;
|
||||
Some(Counted {
|
||||
entity: t,
|
||||
category: Category::from_name(&kind.category),
|
||||
block: [
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: *persistent,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let players = world
|
||||
.players
|
||||
.iter()
|
||||
.map(|(p, mode)| SpawnPlayer {
|
||||
position: *p,
|
||||
spectator: mode == "spectator",
|
||||
})
|
||||
.collect();
|
||||
let mut state = SpawnState::new(
|
||||
&world,
|
||||
get("chunkcount", "289").parse()?,
|
||||
players,
|
||||
&counted,
|
||||
|cx, cz| !world.unloaded.contains(&(cx, cz)),
|
||||
);
|
||||
let mut trace = |e: Event| {
|
||||
log.push(match e {
|
||||
Event::Categories(list) => format!(
|
||||
"cats {}",
|
||||
list.iter().map(|c| c.name()).collect::<Vec<_>>().join(",")
|
||||
),
|
||||
Event::Local(c, ok) => format!("local {} {ok}", c.name()),
|
||||
Event::Start(c, p) => format!("start {} {} {} {}", c.name(), p[0], p[1], p[2]),
|
||||
Event::Try(x, z) => format!("try {x} {z}"),
|
||||
Event::Test(t, p, ok) => format!("test {t} {} {} {} {ok}", p[0], p[1], p[2]),
|
||||
Event::Spawn(t, p, yaw) => format!(
|
||||
"spawn {t} {} {} {} {}",
|
||||
bits(p[0]),
|
||||
bits(p[1]),
|
||||
bits(p[2]),
|
||||
yaw_text(&t, yaw)
|
||||
),
|
||||
})
|
||||
};
|
||||
if mode == "pos" {
|
||||
let category = Category::from_name(&get("cat", "monster"));
|
||||
let v: Vec<i32> = get("start", "0,0,0")
|
||||
.split(',')
|
||||
.map(|n| n.parse().unwrap_or(0))
|
||||
.collect();
|
||||
spawn_selection::spawn_category_for_position(
|
||||
&world,
|
||||
&mut random,
|
||||
&mut state,
|
||||
category,
|
||||
chunk,
|
||||
[v[0], v[1], v[2]],
|
||||
&mut spawn,
|
||||
&mut trace,
|
||||
);
|
||||
} else {
|
||||
let categories = state
|
||||
.filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1");
|
||||
trace(Event::Categories(categories.clone()));
|
||||
spawn_selection::spawn_for_chunk(
|
||||
&world,
|
||||
&mut random,
|
||||
&mut state,
|
||||
&categories,
|
||||
chunk,
|
||||
&mut spawn,
|
||||
&mut trace,
|
||||
);
|
||||
}
|
||||
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
|
||||
}
|
||||
let _ = writeln!(out, "{name} {}", log.join(" ; "));
|
||||
}
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Recipe book placements for tools/test_place_recipe_parity.py: reads one
|
||||
//! JSON case per line (menu, inventory, grid, recipe, max, creative,
|
||||
//! selected) and the oracle's order of the counted items, runs
|
||||
//! `Menu::place_recipe` and prints the oracle's line.
|
||||
//!
|
||||
//! usage: place_recipe_fixture RESOURCES CASES ORDERS OUTPUT
|
||||
use horizon_client::{
|
||||
crafting::Recipes,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
menu::{Env, Kind, Menu, place::PostPlace},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn state(menu: &Menu) -> String {
|
||||
let slots: Vec<String> = (0..menu.slot_count())
|
||||
.map(|i| canon_stack(menu.slot(i)))
|
||||
.collect();
|
||||
let data: String = menu.data.iter().map(|d| format!(" {d}")).collect();
|
||||
let inv: Vec<String> = (0..41).map(|i| canon_stack(menu.player_item(i))).collect();
|
||||
format!(
|
||||
"{} | {} |{data} | {} | inv {}",
|
||||
slots.join(" "),
|
||||
canon_stack(menu.carried.as_ref()),
|
||||
menu.drops.join(";"),
|
||||
inv.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: place_recipe_fixture RESOURCES CASES ORDERS OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let orders = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (line, order) in cases.lines().zip(orders.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 mut menu = Menu::new(kind);
|
||||
menu.creative = case["creative"].as_bool().unwrap_or(false);
|
||||
menu.selected = case["selected"].as_u64().unwrap_or(0) as usize;
|
||||
for pair in case["inventory"].as_array().ok_or("inventory")? {
|
||||
menu.set_player_item(
|
||||
pair[0].as_u64().unwrap_or(0) as usize,
|
||||
parse_stack(pair[1].as_str().unwrap_or("-"))?,
|
||||
);
|
||||
}
|
||||
for pair in case["grid"].as_array().ok_or("grid")? {
|
||||
menu.set_slot(
|
||||
&env,
|
||||
pair[0].as_u64().unwrap_or(0) as usize,
|
||||
parse_stack(pair[1].as_str().unwrap_or("-"))?,
|
||||
);
|
||||
}
|
||||
menu.drops.clear();
|
||||
let order: Vec<String> = order.split_whitespace().map(str::to_owned).collect();
|
||||
let recipe = case["recipe"].as_str().unwrap_or_default();
|
||||
let impossible = recipes.placement(recipe).is_none_or(|p| p.impossible());
|
||||
let post = if impossible {
|
||||
"-"
|
||||
} else {
|
||||
match menu.place_recipe(
|
||||
&env,
|
||||
recipe,
|
||||
case["max"].as_bool().unwrap_or(false),
|
||||
menu.creative,
|
||||
Some(&order),
|
||||
) {
|
||||
PostPlace::Nothing => "NOTHING",
|
||||
PostPlace::Ghost => "PLACE_GHOST_RECIPE",
|
||||
}
|
||||
};
|
||||
out += &format!("post {post} | {}\n", state(&menu));
|
||||
}
|
||||
fs::write(args.get(4).ok_or(usage)?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,143 @@
|
||||
//! The player's recipe book for tools/test_recipe_book_parity.py: reads one
|
||||
//! JSON case per line (a list of give / take / seen / set / save / load ops)
|
||||
//! and prints the oracle's line after each op, from
|
||||
//! `horizon_client::recipe_book::{Settings, RecipeIndex}`.
|
||||
//!
|
||||
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
|
||||
fn fnv<'a>(ids: impl IntoIterator<Item = &'a String>) -> u64 {
|
||||
let mut sorted: Vec<&String> = ids.into_iter().collect();
|
||||
sorted.sort();
|
||||
let joined = sorted
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in joined.bytes() {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
fn ids(set: &BTreeSet<String>) -> String {
|
||||
format!("{}:{:x}", set.len(), fnv(set))
|
||||
}
|
||||
/// The saved compound as the oracle prints it: keys sorted, lists hashed.
|
||||
fn compound(tag: &Tag) -> String {
|
||||
let Tag::Compound(c) = tag else {
|
||||
return String::new();
|
||||
};
|
||||
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
|
||||
keys.sort();
|
||||
let mut out = String::new();
|
||||
for k in keys {
|
||||
out.push(' ');
|
||||
out.push_str(&k);
|
||||
out.push('=');
|
||||
match tag.get(&k) {
|
||||
Some(Tag::List(list)) => {
|
||||
let v: Vec<String> = list
|
||||
.iter()
|
||||
.map(|t| match t {
|
||||
Tag::String(s) => s.to_string_lossy(),
|
||||
_ => "?".into(),
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&format!("{}:{:x}", v.len(), fnv(&v)));
|
||||
}
|
||||
Some(Tag::Byte(b)) => out.push_str(&format!("{b}b")),
|
||||
_ => out.push('?'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: recipe_book_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let mut out = String::new();
|
||||
for line in cases.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let mut book = Settings::default();
|
||||
let mut saved: Option<Tag> = None;
|
||||
for op in case["ops"].as_array().ok_or("ops")? {
|
||||
let kind = op["op"].as_str().unwrap_or_default();
|
||||
let list = || -> Vec<String> {
|
||||
op["ids"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.filter(|id| index.contains(id))
|
||||
.collect()
|
||||
};
|
||||
let mut count = 0;
|
||||
let mut extra = String::new();
|
||||
match kind {
|
||||
"give" => count = book.add_recipes(&index, &list()).0,
|
||||
"take" => count = book.remove_recipes(&index, &list()),
|
||||
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
|
||||
"set" => {
|
||||
let i = match op["type"].as_str().unwrap_or_default() {
|
||||
"CRAFTING" => BookType::Crafting,
|
||||
"FURNACE" => BookType::Furnace,
|
||||
"BLAST_FURNACE" => BookType::BlastFurnace,
|
||||
_ => BookType::Smoker,
|
||||
}
|
||||
.index();
|
||||
book.open[i] = op["open"].as_bool().unwrap_or(false);
|
||||
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
|
||||
}
|
||||
"save" => {
|
||||
let tag = book.write();
|
||||
extra = compound(&tag);
|
||||
saved = Some(tag);
|
||||
}
|
||||
"load" => {
|
||||
let mut tag = saved
|
||||
.clone()
|
||||
.unwrap_or_else(|| Tag::Compound(Default::default()));
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
for name in ["recipes", "toBeDisplayed"] {
|
||||
if let Some(Tag::List(l)) = c.get_mut(&name.into()) {
|
||||
l.push(Tag::String("minecraft:no_such_recipe".into()));
|
||||
l.push(Tag::String("stick".into()));
|
||||
}
|
||||
}
|
||||
if let Some(drop) = op["drop"].as_str() {
|
||||
c.remove(&drop.into());
|
||||
}
|
||||
}
|
||||
book = Settings::read_valid(Some(&tag), &|id| index.contains(id));
|
||||
}
|
||||
other => return Err(format!("op {other}").into()),
|
||||
}
|
||||
let settings: String = BookType::ALL
|
||||
.iter()
|
||||
.map(|b| {
|
||||
format!(
|
||||
"{}{}",
|
||||
u8::from(book.open[b.index()]),
|
||||
u8::from(book.filtering[b.index()])
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out += &format!(
|
||||
"{kind} {count} known={} highlight={} settings={settings}{extra}\n",
|
||||
ids(&book.recipes),
|
||||
ids(&book.to_be_displayed)
|
||||
);
|
||||
}
|
||||
out += "end\n";
|
||||
}
|
||||
fs::write(args.get(3).ok_or(usage)?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! The recipe book's drawing, for tools/test_recipe_book_render_parity.py:
|
||||
//! reads one JSON case per line (book type, GUI size, tabs, search box,
|
||||
//! filter, recipe collections and page, overlay, ghost slots, cycling index,
|
||||
//! mouse) and writes the calls `horizon_client::recipe_book_screen` makes,
|
||||
//! in the oracle's JSON form.
|
||||
//!
|
||||
//! usage: recipe_book_render_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::{
|
||||
crafting::Recipes,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
items::{ItemStack, Items},
|
||||
lang,
|
||||
recipe_book::{Book, BookType, Entry},
|
||||
recipe_book_screen::{
|
||||
ButtonView, Call, GhostView, ItemCall, OverlayRecipe, OverlayView, Run, Screen, Search,
|
||||
TabView, draw, overlay_cells, overlay_origin,
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
fn runs(r: &[Run]) -> Value {
|
||||
Value::Array(
|
||||
r.iter()
|
||||
.map(|r| json!([r.text, r.color, r.italic]))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
fn round(v: f32) -> f32 {
|
||||
(v * 1e4).round() / 1e4
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: recipe_book_render_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let book = Book::build(&recipes, &items);
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
let root_buf = root.to_path_buf();
|
||||
lang::set(lang::Language::load(&[&root_buf], lang::DEFAULT));
|
||||
let translate = |key: &str| lang::get(key);
|
||||
let text_width = |s: &str| HudBatch::text_width(&atlas, s) as i32;
|
||||
let mut out = String::new();
|
||||
for line in cases.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
|
||||
let kind = match case["type"].as_str().unwrap_or_default() {
|
||||
"crafting" => BookType::Crafting,
|
||||
"furnace" => BookType::Furnace,
|
||||
"blast_furnace" => BookType::BlastFurnace,
|
||||
_ => BookType::Smoker,
|
||||
};
|
||||
let filtering = case["filtering"].as_bool().unwrap_or(false);
|
||||
let tab_list = kind.tabs();
|
||||
let tabs: Vec<TabView> = case["tabs"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|t| {
|
||||
let (a, b) = tab_list[t["tab"].as_u64().unwrap_or(0) as usize].icons();
|
||||
TabView {
|
||||
icons: (a.to_owned(), b.map(str::to_owned)),
|
||||
selected: t["selected"].as_bool().unwrap_or(false),
|
||||
anim: t["anim"].as_f64().unwrap_or(0.0) as f32,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let s = &case["search"];
|
||||
let search = Search {
|
||||
value: s["value"].as_str().unwrap_or_default().to_owned(),
|
||||
cursor: s["cursor"].as_u64().unwrap_or(0) as usize,
|
||||
highlight: s["highlight"].as_u64().unwrap_or(0) as usize,
|
||||
display: s["display"].as_u64().unwrap_or(0) as usize,
|
||||
focused: s["focused"].as_bool().unwrap_or(false),
|
||||
blink_ms: s["blink"].as_u64().unwrap_or(0),
|
||||
};
|
||||
// Each collection's recipes and craftable flags.
|
||||
let collections: Vec<Vec<(&Entry, bool)>> = case["collections"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|c| {
|
||||
c["recipes"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|r| Some((book.entry(r[0].as_str()?)?, r[1].as_bool()?)))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let page = case["page"].as_u64().unwrap_or(0) as usize;
|
||||
let pages = collections.len().div_ceil(20);
|
||||
let anims = case["buttonAnims"].as_array().cloned().unwrap_or_default();
|
||||
let shown = &collections[(20 * page).min(collections.len())..];
|
||||
let buttons: Vec<ButtonView> = shown
|
||||
.iter()
|
||||
.take(20)
|
||||
.enumerate()
|
||||
.map(|(i, c)| ButtonView {
|
||||
has_craftable: c.iter().any(|e| e.1),
|
||||
entries: c
|
||||
.iter()
|
||||
.filter(|e| e.1 || !filtering)
|
||||
.map(|e| vec![e.0.result.clone()])
|
||||
.collect(),
|
||||
anim: anims.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32,
|
||||
})
|
||||
.collect();
|
||||
let (xo, yo) = Screen {
|
||||
book: kind,
|
||||
width: int(&case["width"]),
|
||||
height: int(&case["height"]),
|
||||
narrow: case["narrow"].as_bool().unwrap_or(false),
|
||||
mouse: (0, 0),
|
||||
index: 0,
|
||||
filtering,
|
||||
search: &search,
|
||||
tabs: &[],
|
||||
buttons: &[],
|
||||
page,
|
||||
pages,
|
||||
overlay: None,
|
||||
ghost: None,
|
||||
translate: &translate,
|
||||
text_width: &text_width,
|
||||
}
|
||||
.origin();
|
||||
let overlay = case["overlay"].as_u64().map(|i| {
|
||||
let i = i as usize;
|
||||
let c = &shown[i];
|
||||
let mut recipes: Vec<OverlayRecipe> = c
|
||||
.iter()
|
||||
.filter(|e| e.1)
|
||||
.map(|e| OverlayRecipe {
|
||||
craftable: true,
|
||||
cells: overlay_cells(&book, e.0),
|
||||
})
|
||||
.collect();
|
||||
if !filtering {
|
||||
recipes.extend(c.iter().filter(|e| !e.1).map(|e| OverlayRecipe {
|
||||
craftable: false,
|
||||
cells: overlay_cells(&book, e.0),
|
||||
}));
|
||||
}
|
||||
let (x, y) = overlay_origin(
|
||||
xo + 11 + 25 * (i as i32 % 5),
|
||||
yo + 31 + 25 * (i as i32 / 5),
|
||||
recipes.len(),
|
||||
xo,
|
||||
yo,
|
||||
);
|
||||
OverlayView { x, y, recipes }
|
||||
});
|
||||
let ghosts: Vec<GhostView> = case["ghost"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|g| GhostView {
|
||||
x: int(&g["x"]),
|
||||
y: int(&g["y"]),
|
||||
result: g["result"].as_bool().unwrap_or(false),
|
||||
items: g["items"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(|i| ItemStack::new(i, 1))
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
let screen = Screen {
|
||||
book: kind,
|
||||
width: int(&case["width"]),
|
||||
height: int(&case["height"]),
|
||||
narrow: case["narrow"].as_bool().unwrap_or(false),
|
||||
mouse: (int(&case["mouse"][0]), int(&case["mouse"][1])),
|
||||
index: case["index"].as_u64().unwrap_or(0) as usize,
|
||||
filtering,
|
||||
search: &search,
|
||||
tabs: &tabs,
|
||||
buttons: &buttons,
|
||||
page,
|
||||
pages,
|
||||
overlay: overlay.as_ref(),
|
||||
ghost: case.get("ghost").map(|_| {
|
||||
(
|
||||
int(&case["left"]),
|
||||
int(&case["top"]),
|
||||
case["big"].as_bool().unwrap_or(false),
|
||||
ghosts.as_slice(),
|
||||
)
|
||||
}),
|
||||
translate: &translate,
|
||||
text_width: &text_width,
|
||||
};
|
||||
let draws: Vec<Value> = draw(&screen)
|
||||
.into_iter()
|
||||
.filter_map(|d| {
|
||||
let m: Vec<f32> = d.pose.iter().map(|v| round(*v)).collect();
|
||||
let mut o = match d.call {
|
||||
Call::Blit {
|
||||
texture,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
u,
|
||||
v,
|
||||
tw,
|
||||
th,
|
||||
} => json!({"t": "blit", "texture": texture, "x": x, "y": y, "w": w, "h": h,
|
||||
"u": u, "v": v, "tw": tw, "th": th}),
|
||||
Call::Sprite { id, x, y, w, h } => {
|
||||
json!({"t": "sprite", "id": id, "x": x, "y": y, "w": w, "h": h})
|
||||
}
|
||||
Call::Fill {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
color,
|
||||
} => json!({"t": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": color}),
|
||||
Call::Highlight { x0, y0, x1, y1 } => {
|
||||
json!({"t": "highlight", "x0": x0, "y0": y0, "x1": x1, "y1": y1})
|
||||
}
|
||||
Call::Text {
|
||||
runs: r,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
shadow,
|
||||
} => json!({"t": "text", "runs": runs(&r), "x": x, "y": y, "color": color, "shadow": shadow}),
|
||||
Call::Item { call, stack, x, y } => {
|
||||
if stack.count == 0 || &*stack.item == "minecraft:air" {
|
||||
return None;
|
||||
}
|
||||
let t = match call {
|
||||
ItemCall::Item => "item",
|
||||
ItemCall::Fake => "fake",
|
||||
ItemCall::Decorations => "decorations",
|
||||
};
|
||||
json!({"t": t, "id": &*stack.item, "count": stack.count, "x": x, "y": y})
|
||||
}
|
||||
Call::Tooltip(lines) => {
|
||||
json!({"t": "tooltip", "lines": lines.iter().map(|l| runs(l)).collect::<Vec<_>>()})
|
||||
}
|
||||
Call::ItemTooltip { stack, more } => {
|
||||
json!({"t": "item_tooltip", "id": &*stack.item, "more": more})
|
||||
}
|
||||
Call::Stratum => json!({"t": "stratum"}),
|
||||
};
|
||||
o["m"] = json!(m);
|
||||
Some(o)
|
||||
})
|
||||
.collect();
|
||||
let _ = writeln!(out, "{}", json!({ "draws": draws }));
|
||||
}
|
||||
fs::write(args.get(3).ok_or(usage)?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! What the recipe book lists, for tools/test_recipe_book_view_parity.py:
|
||||
//! reads one JSON case per line (the history of displays the client was
|
||||
//! sent, the book type and grid, the inventory and menu slots, the filter)
|
||||
//! and prints every tab's visibility, page count and buttons from
|
||||
//! `horizon_client::recipe_book::ClientBook`.
|
||||
//!
|
||||
//! usage: recipe_book_view_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::{
|
||||
crafting::Recipes,
|
||||
item_data::parse_stack,
|
||||
items::Items,
|
||||
menu::place::Stacked,
|
||||
recipe_book::{Book, BookType, ClientBook, Display, RecipeIndex, Tab},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: recipe_book_view_fixture RESOURCES CASES OUTPUT";
|
||||
let root = Path::new(args.get(1).ok_or(usage)?);
|
||||
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let index = RecipeIndex::load(root);
|
||||
let mut book = Book::build(&recipes, &items);
|
||||
book.set_display_ids(&index);
|
||||
let max = |s: &horizon_client::items::ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
|
||||
let mut out = String::new();
|
||||
for line in cases.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
let mut client = ClientBook::default();
|
||||
for batch in case["history"].as_array().ok_or("history")? {
|
||||
let op = batch["op"].as_str().unwrap_or_default();
|
||||
if op == "replace" {
|
||||
client.clear();
|
||||
}
|
||||
for id in batch["ids"].as_array().into_iter().flatten() {
|
||||
for d in index.display_ids(id.as_str().unwrap_or_default()) {
|
||||
if op == "remove" {
|
||||
client.remove(d);
|
||||
} else {
|
||||
client.add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let kind = case["type"].as_str().unwrap_or_default();
|
||||
let grid = case["grid"].as_u64().unwrap_or(3) as usize;
|
||||
let mut stock = Stacked::default();
|
||||
for s in case["inventory"].as_array().into_iter().flatten() {
|
||||
if let Some(stack) = parse_stack(s.as_str().unwrap_or("-"))? {
|
||||
stock.account_simple(&stack, max(&stack));
|
||||
}
|
||||
}
|
||||
for s in case["slots"].as_array().into_iter().flatten() {
|
||||
if let Some(stack) = parse_stack(s.as_str().unwrap_or("-"))? {
|
||||
if kind == "crafting" {
|
||||
stock.account_simple(&stack, max(&stack));
|
||||
} else {
|
||||
stock.account_stack(&stack, max(&stack));
|
||||
}
|
||||
}
|
||||
}
|
||||
let book_type = match kind {
|
||||
"crafting" => BookType::Crafting,
|
||||
"furnace" => BookType::Furnace,
|
||||
"blast_furnace" => BookType::BlastFurnace,
|
||||
_ => BookType::Smoker,
|
||||
};
|
||||
let filtering = case["filtering"].as_bool().unwrap_or(false);
|
||||
for tab in book_type.tabs() {
|
||||
let collections = client.collections(&book, book_type, tab);
|
||||
let selected = |i: usize| match (&book.entries[i].display, book_type) {
|
||||
(Display::Furnace(_), BookType::Crafting) => false,
|
||||
(Display::Furnace(_), _) => true,
|
||||
(_, BookType::Crafting) => book.entries[i].fits(grid, grid),
|
||||
_ => false,
|
||||
};
|
||||
let craftable = |i: usize| selected(i) && book.can_craft(&book.entries[i], &stock);
|
||||
let visible = tab == Tab::Search
|
||||
|| collections
|
||||
.iter()
|
||||
.any(|c| c.entries.iter().any(|&i| selected(i)));
|
||||
let shown: Vec<_> = collections
|
||||
.iter()
|
||||
.filter(|c| c.entries.iter().any(|&i| selected(i)))
|
||||
.filter(|c| !filtering || c.entries.iter().any(|&i| craftable(i)))
|
||||
.collect();
|
||||
let name = match tab {
|
||||
Tab::Search => "search".to_owned(),
|
||||
Tab::Category(c) => c.name().to_owned(),
|
||||
};
|
||||
let _ = write!(
|
||||
out,
|
||||
"{name} {} pages={}",
|
||||
if visible { "shown" } else { "hidden" },
|
||||
shown.len().div_ceil(20)
|
||||
);
|
||||
for c in shown {
|
||||
let parts: Vec<String> = c
|
||||
.entries
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
format!(
|
||||
"{}{}{}",
|
||||
book.entries[i].display_id,
|
||||
if selected(i) { "s" } else { "" },
|
||||
if craftable(i) { "c" } else { "" }
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let _ = write!(out, " [{}]", parts.join(","));
|
||||
}
|
||||
out.push_str(" | ");
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
fs::write(args.get(3).ok_or(usage)?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! The scoreboard model for differential tests against vanilla's `Scoreboard`
|
||||
//! (tools/test_scoreboard_parity.py): runs the same scripted operations as
|
||||
//! VanillaScoreboardOracle on `horizon_client::scoreboard::Scoreboard` and
|
||||
//! prints the same JSON lines (results, dumps, the packed save data before
|
||||
//! and after reloading it). With a vanilla output file it also reloads each
|
||||
//! case's vanilla NBT and packs it again (`vreload`).
|
||||
//!
|
||||
//! usage: scoreboard_fixture REGISTRIES.json INPUTS OUTPUT [VANILLA_OUTPUT]
|
||||
use horizon_client::{
|
||||
command::Registries,
|
||||
scoreboard::{
|
||||
self as sb, CollisionRule, Comp, NumberFormat, RenderType, Scoreboard, Visibility,
|
||||
canonical_component, criteria_by_name, criteria_read_only, criteria_render_type,
|
||||
},
|
||||
};
|
||||
use horizon_world::nbt::{self, NamedTag, Tag};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{collections::BTreeMap, fs, io::Write};
|
||||
|
||||
fn canon(tag: &Tag) -> Value {
|
||||
match tag {
|
||||
Tag::Compound(c) => {
|
||||
let sorted: BTreeMap<String, &Tag> =
|
||||
c.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
|
||||
Value::Object(sorted.into_iter().map(|(k, v)| (k, canon(v))).collect())
|
||||
}
|
||||
Tag::List(l) => Value::Array(l.iter().map(canon).collect()),
|
||||
Tag::String(s) => Value::String(format!("'{}", s.to_string_lossy())),
|
||||
Tag::Byte(v) => Value::String(format!("{v}b")),
|
||||
Tag::Short(v) => Value::String(format!("{v}s")),
|
||||
Tag::Int(v) => Value::String(format!("{v}i")),
|
||||
Tag::Long(v) => Value::String(format!("{v}L")),
|
||||
Tag::Float(v) => Value::String(format!("f:{v:x}")),
|
||||
Tag::Double(v) => Value::String(format!("d:{v:x}")),
|
||||
Tag::IntArray(v) => json!({ "I;": v }),
|
||||
other => Value::String(format!("?{other:?}")),
|
||||
}
|
||||
}
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
fn unhex(text: &str) -> Vec<u8> {
|
||||
(0..text.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
fn encode(tag: &Tag) -> Vec<u8> {
|
||||
nbt::write_named(
|
||||
&NamedTag {
|
||||
name: "".into(),
|
||||
tag: tag.clone(),
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.expect("nbt")
|
||||
}
|
||||
fn decode(bytes: &[u8]) -> Tag {
|
||||
nbt::read_named(bytes, Default::default())
|
||||
.expect("nbt")
|
||||
.0
|
||||
.tag
|
||||
}
|
||||
|
||||
fn comp(v: Option<&Value>) -> Option<Comp> {
|
||||
v.filter(|v| !v.is_null()).cloned()
|
||||
}
|
||||
fn comp_json(c: Option<&Comp>) -> Value {
|
||||
c.map_or(Value::Null, canonical_component)
|
||||
}
|
||||
fn fmt_json(f: Option<&NumberFormat>) -> Value {
|
||||
f.map_or(Value::Null, NumberFormat::to_json)
|
||||
}
|
||||
fn fmt(v: Option<&Value>) -> Option<NumberFormat> {
|
||||
v.filter(|v| !v.is_null()).and_then(NumberFormat::from_json)
|
||||
}
|
||||
fn s<'a>(op: &'a Value, k: &str) -> &'a str {
|
||||
op[k].as_str().unwrap_or_default()
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
reg: Registries,
|
||||
}
|
||||
impl Fixture {
|
||||
fn criteria(&self, name: &str) -> Value {
|
||||
match criteria_by_name(name, &self.reg) {
|
||||
Ok(Some(c)) => Value::String(c),
|
||||
Ok(None) => Value::Null,
|
||||
Err(_) => Value::String("!invalid".into()),
|
||||
}
|
||||
}
|
||||
fn dump(&self, b: &Scoreboard, probes: &[Value]) -> Value {
|
||||
let objectives: Vec<Value> = b
|
||||
.objectives
|
||||
.iter()
|
||||
.map(|o| {
|
||||
json!({
|
||||
"name": o.name, "criteria": o.criteria, "display": comp_json(Some(&o.display_name)),
|
||||
"render": o.render_type.id(), "auto": o.display_auto_update,
|
||||
"format": fmt_json(o.number_format.as_ref()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let stream: Vec<Value> = b
|
||||
.objectives_streamed()
|
||||
.iter()
|
||||
.map(|o| Value::String(o.name.clone()))
|
||||
.collect();
|
||||
let tracked = b.tracked();
|
||||
let mut lists = Map::new();
|
||||
for o in &b.objectives {
|
||||
let rows: Vec<Value> = b
|
||||
.list_scores(&o.name)
|
||||
.iter()
|
||||
.map(|e| {
|
||||
json!([
|
||||
e.owner,
|
||||
e.value,
|
||||
comp_json(e.display.as_ref()),
|
||||
fmt_json(e.number_format.as_ref())
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
lists.insert(o.name.clone(), Value::Array(rows));
|
||||
}
|
||||
let mut holders = Map::new();
|
||||
for h in &tracked {
|
||||
let mut per = Map::new();
|
||||
for o in &b.objectives {
|
||||
if let Some(sc) = b.score(h, &o.name) {
|
||||
per.insert(o.name.clone(), score_json(sc));
|
||||
}
|
||||
}
|
||||
holders.insert(h.clone(), Value::Object(per));
|
||||
}
|
||||
let teams: Vec<Value> = b
|
||||
.teams
|
||||
.iter()
|
||||
.map(|t| {
|
||||
json!({
|
||||
"name": t.name, "display": comp_json(Some(&t.display_name)), "color": t.color,
|
||||
"ff": t.allow_friendly_fire, "si": t.see_friendly_invisibles,
|
||||
"prefix": comp_json(Some(&t.prefix)), "suffix": comp_json(Some(&t.suffix)),
|
||||
"nametag": t.nametag_visibility.name(), "death": t.death_message_visibility.name(),
|
||||
"collision": t.collision_rule.name(),
|
||||
"players": t.players.iter().collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let teams_stream: Vec<Value> = b
|
||||
.teams_streamed()
|
||||
.iter()
|
||||
.map(|t| Value::String(t.name.clone()))
|
||||
.collect();
|
||||
let mut slots = Map::new();
|
||||
for slot in sb::display_slots() {
|
||||
if let Some(o) = b.display_objective(&slot) {
|
||||
slots.insert(slot, Value::String(o.name.clone()));
|
||||
}
|
||||
}
|
||||
let mut team_of = Map::new();
|
||||
for p in probes {
|
||||
let p = p.as_str().unwrap_or_default();
|
||||
team_of.insert(
|
||||
p.to_owned(),
|
||||
b.players_team(p)
|
||||
.map_or(Value::Null, |t| Value::String(t.name.clone())),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"objectives": objectives, "objectives_stream": stream, "tracked": tracked,
|
||||
"lists": lists, "holders": holders, "teams": teams, "teams_stream": teams_stream,
|
||||
"slots": slots, "team_of": team_of,
|
||||
})
|
||||
}
|
||||
fn apply(&self, b: &mut Scoreboard, op: &Value, probes: &[Value]) -> Value {
|
||||
let kind = s(op, "op");
|
||||
let holder = s(op, "holder");
|
||||
let dname = comp(op.get("dname"));
|
||||
match kind {
|
||||
"crit" => {
|
||||
let name = self.criteria(s(op, "name"));
|
||||
let mut o = json!({ "name": name });
|
||||
if let Some(n) = name.as_str().filter(|n| !n.starts_with('!')) {
|
||||
o["readonly"] = Value::Bool(criteria_read_only(n));
|
||||
o["render"] = Value::String(criteria_render_type(n).id().into());
|
||||
}
|
||||
o
|
||||
}
|
||||
"obj_add" => {
|
||||
let Some(criteria) = self
|
||||
.criteria(s(op, "criteria"))
|
||||
.as_str()
|
||||
.filter(|c| !c.starts_with('!'))
|
||||
.map(str::to_owned)
|
||||
else {
|
||||
return json!("bad");
|
||||
};
|
||||
if b.objective(s(op, "name")).is_some() {
|
||||
return json!("exists");
|
||||
}
|
||||
b.add_objective(
|
||||
s(op, "name"),
|
||||
&criteria,
|
||||
comp(op.get("display")).unwrap_or(Value::Null),
|
||||
RenderType::by_id(s(op, "render")),
|
||||
op["auto"].as_bool().unwrap_or(false),
|
||||
fmt(op.get("fmt")),
|
||||
);
|
||||
json!("ok")
|
||||
}
|
||||
"obj_rm" => {
|
||||
if b.objective(s(op, "name")).is_none() {
|
||||
return json!("missing");
|
||||
}
|
||||
b.remove_objective(s(op, "name"));
|
||||
json!("ok")
|
||||
}
|
||||
"obj_mod" => {
|
||||
let Some(o) = b.objective_mut(s(op, "name")) else {
|
||||
return json!("missing");
|
||||
};
|
||||
let v = &op["value"];
|
||||
match s(op, "field") {
|
||||
"display" => o.display_name = v.clone(),
|
||||
"render" => o.render_type = RenderType::by_id(v.as_str().unwrap_or_default()),
|
||||
"auto" => o.display_auto_update = v.as_bool().unwrap_or(false),
|
||||
"fmt" => o.number_format = fmt(Some(v)),
|
||||
other => panic!("field {other}"),
|
||||
}
|
||||
json!("ok")
|
||||
}
|
||||
"slot" => {
|
||||
let name = op["name"].as_str();
|
||||
if let Some(n) = name
|
||||
&& b.objective(n).is_none()
|
||||
{
|
||||
return json!("missing");
|
||||
}
|
||||
b.set_display_objective(s(op, "slot"), name);
|
||||
json!("ok")
|
||||
}
|
||||
"set" | "add" | "get" | "lock" | "unlock" | "sdisplay" | "sfmt" => {
|
||||
let name = s(op, "name");
|
||||
let Some(criteria) = b.objective(name).map(|o| o.criteria.clone()) else {
|
||||
return json!("missing");
|
||||
};
|
||||
let force = op["force"].as_bool().unwrap_or(false);
|
||||
let writable = force || !criteria_read_only(&criteria);
|
||||
let current = b.get(holder, name);
|
||||
match kind {
|
||||
"set" | "add" => {
|
||||
if !writable {
|
||||
return json!("readonly");
|
||||
}
|
||||
let value = op["value"].as_i64().unwrap_or(0) as i32;
|
||||
let new = if kind == "add" {
|
||||
current.wrapping_add(value)
|
||||
} else {
|
||||
value
|
||||
};
|
||||
b.set_with(holder, name, new, dname.as_ref());
|
||||
if kind == "add" {
|
||||
return json!(new);
|
||||
}
|
||||
}
|
||||
"get" => return json!(current),
|
||||
"lock" => b.score_mut(holder, name).locked = true,
|
||||
"unlock" => b.score_mut(holder, name).locked = false,
|
||||
"sdisplay" => b.score_mut(holder, name).display = comp(op.get("value")),
|
||||
"sfmt" => b.score_mut(holder, name).number_format = fmt(op.get("value")),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
json!("ok")
|
||||
}
|
||||
"info" => {
|
||||
let name = s(op, "name");
|
||||
if b.objective(name).is_none() {
|
||||
return json!("missing");
|
||||
}
|
||||
b.score(holder, name).map_or(Value::Null, score_json)
|
||||
}
|
||||
"reset" => {
|
||||
match op["name"].as_str() {
|
||||
None => b.reset_all(holder),
|
||||
Some(name) => {
|
||||
if b.objective(name).is_none() {
|
||||
return json!("missing");
|
||||
}
|
||||
b.reset_one(holder, name);
|
||||
}
|
||||
}
|
||||
json!("ok")
|
||||
}
|
||||
"all" => {
|
||||
let criteria = self.criteria(s(op, "criteria"));
|
||||
let criteria = criteria.as_str().unwrap_or_default();
|
||||
let value = op["value"].as_i64().unwrap_or(0) as i32;
|
||||
let k = s(op, "kind");
|
||||
b.for_all_objectives_with(criteria, holder, dname.as_ref(), |v| match k {
|
||||
"inc" => v.wrapping_add(1),
|
||||
"add" => v.wrapping_add(value),
|
||||
"set" => value,
|
||||
_ => 0,
|
||||
});
|
||||
json!("ok")
|
||||
}
|
||||
"team_add" => {
|
||||
if b.team(s(op, "name")).is_some() {
|
||||
return json!("exists");
|
||||
}
|
||||
b.add_team(s(op, "name"));
|
||||
json!("ok")
|
||||
}
|
||||
"team_rm" => {
|
||||
if b.team(s(op, "name")).is_none() {
|
||||
return json!("missing");
|
||||
}
|
||||
b.remove_team(s(op, "name"));
|
||||
json!("ok")
|
||||
}
|
||||
"join" => {
|
||||
if b.team(s(op, "team")).is_none() {
|
||||
return json!("missing");
|
||||
}
|
||||
json!(b.add_to_team(holder, s(op, "team")))
|
||||
}
|
||||
"leave" => json!(b.remove_from_team(holder)),
|
||||
"team_set" => {
|
||||
let Some(t) = b.team_mut(s(op, "name")) else {
|
||||
return json!("missing");
|
||||
};
|
||||
let v = &op["value"];
|
||||
let name = |v: &Value| v.as_str().unwrap_or_default().to_owned();
|
||||
match s(op, "field") {
|
||||
"display" => t.display_name = v.clone(),
|
||||
"color" => t.color = v.as_str().map(str::to_owned),
|
||||
"ff" => t.allow_friendly_fire = v.as_bool().unwrap_or(true),
|
||||
"si" => t.see_friendly_invisibles = v.as_bool().unwrap_or(true),
|
||||
"prefix" => t.prefix = comp(Some(v)).unwrap_or_else(|| sb::literal("")),
|
||||
"suffix" => t.suffix = comp(Some(v)).unwrap_or_else(|| sb::literal("")),
|
||||
"nametag" => {
|
||||
t.nametag_visibility = Visibility::by_name(&name(v)).expect("visibility")
|
||||
}
|
||||
"death" => {
|
||||
t.death_message_visibility =
|
||||
Visibility::by_name(&name(v)).expect("visibility")
|
||||
}
|
||||
"collision" => {
|
||||
t.collision_rule = CollisionRule::by_name(&name(v)).expect("collision")
|
||||
}
|
||||
other => panic!("field {other}"),
|
||||
}
|
||||
json!("ok")
|
||||
}
|
||||
"removed" => {
|
||||
b.entity_removed(holder);
|
||||
json!("ok")
|
||||
}
|
||||
"allied" => json!(b.allied(s(op, "a"), s(op, "b"))),
|
||||
"dump" => self.dump(b, probes),
|
||||
other => panic!("op {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn score_json(sc: &sb::Score) -> Value {
|
||||
json!({
|
||||
"value": sc.value, "locked": sc.locked, "display": comp_json(sc.display.as_ref()),
|
||||
"format": fmt_json(sc.number_format.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let registries: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&args[1]).expect("registries")).expect("json");
|
||||
let list = |k: &str| -> Vec<String> {
|
||||
registries[k]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let reg = Registries {
|
||||
items: list("items"),
|
||||
blocks: list("blocks")
|
||||
.into_iter()
|
||||
.map(|b| (b, Vec::new()))
|
||||
.collect(),
|
||||
entity_types: list("entity_types"),
|
||||
..Registries::default()
|
||||
};
|
||||
let fixture = Fixture { reg };
|
||||
let vanilla: BTreeMap<i64, String> = args
|
||||
.get(4)
|
||||
.and_then(|p| fs::read_to_string(p).ok())
|
||||
.map(|text| {
|
||||
text.lines()
|
||||
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
|
||||
.filter_map(|v| Some((v["case"].as_i64()?, v["nbt"].as_str()?.to_owned())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut out = std::io::BufWriter::new(fs::File::create(&args[3]).expect("output"));
|
||||
for line in fs::read_to_string(&args[2]).expect("inputs").lines() {
|
||||
let case: Value = serde_json::from_str(line).expect("case");
|
||||
let probes = case["probes"].as_array().cloned().unwrap_or_default();
|
||||
let mut board = Scoreboard::default();
|
||||
let outs: Vec<Value> = case["ops"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|op| fixture.apply(&mut board, op, &probes))
|
||||
.collect();
|
||||
let packed = board.pack();
|
||||
let bytes = encode(&packed);
|
||||
let reloaded = Scoreboard::unpack(&decode(&bytes));
|
||||
let mut result = json!({
|
||||
"case": case["case"], "out": outs, "pack": canon(&packed), "nbt": hex(&bytes),
|
||||
"reload": canon(&reloaded.pack()), "reload_dump": fixture.dump(&reloaded, &probes),
|
||||
});
|
||||
if let Some(v) = case["case"].as_i64().and_then(|c| vanilla.get(&c)) {
|
||||
result["vreload"] = canon(&Scoreboard::unpack(&decode(&unhex(v))).pack());
|
||||
}
|
||||
writeln!(out, "{result}").expect("write");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Loom and crafter screen drawing for tools/test_screen_layout_parity.py:
|
||||
//! reads one JSON case per line and prints the `horizon_client::screen_layout`
|
||||
//! calls in the format of `VanillaLoomScreenOracle`.
|
||||
//!
|
||||
//! usage: screen_layout_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
banner::{BannerLayer, Banners, banner_color},
|
||||
screen_layout::{self, CrafterView, LoomView, ScreenDraw},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, path::Path, sync::Arc};
|
||||
|
||||
fn draw_json(d: &ScreenDraw) -> Value {
|
||||
let round = |v: f32| ((v * 1e6).round() / 1e6) as f64;
|
||||
match d {
|
||||
ScreenDraw::Gradient { x1, y1 } => json!({"t": "gradient", "x1": x1, "y1": y1}),
|
||||
ScreenDraw::Blit {
|
||||
texture,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
tw,
|
||||
th,
|
||||
} => {
|
||||
json!({"t": "blit", "texture": texture, "x": x, "y": y, "w": w, "h": h, "u": 0.0, "v": 0.0, "tw": tw, "th": th})
|
||||
}
|
||||
ScreenDraw::Sprite { id, x, y, w, h } => {
|
||||
json!({"t": "sprite", "id": id, "x": x, "y": y, "w": w, "h": h})
|
||||
}
|
||||
ScreenDraw::Banner {
|
||||
base,
|
||||
layers,
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
} => {
|
||||
let layers: Vec<Value> = layers
|
||||
.iter()
|
||||
.map(|l| json!([&*l.pattern, &*l.color]))
|
||||
.collect();
|
||||
json!({"t": "banner", "base": base, "layers": layers, "x0": x0, "y0": y0, "x1": x1, "y1": y1})
|
||||
}
|
||||
ScreenDraw::Tooltip { key } => json!({"t": "tooltip", "key": key}),
|
||||
ScreenDraw::SpriteOf { id } => {
|
||||
json!({"t": "sprite_of", "id": id.trim_start_matches("minecraft:")})
|
||||
}
|
||||
ScreenDraw::Fill {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
color,
|
||||
} => json!({"t": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": color}),
|
||||
ScreenDraw::Atlas { x0, y0, x1, y1, uv } => json!({
|
||||
"t": "atlas", "x0": x0, "y0": y0, "x1": x1, "y1": y1,
|
||||
"u0": round(uv[0]), "u1": round(uv[1]), "v0": round(uv[2]), "v1": round(uv[3])
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let usage = "usage: screen_layout_fixture RESOURCES INPUT OUTPUT";
|
||||
let banners = Banners::load(Path::new(args.get(1).ok_or(usage)?));
|
||||
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
|
||||
let mut out = String::new();
|
||||
for line in input.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let c: Value = serde_json::from_str(line)?;
|
||||
let int = |k: &str| c[k].as_i64().unwrap_or(0) as i32;
|
||||
let draws = if c["screen"] == "loom" {
|
||||
let patterns: Vec<Arc<str>> = c["patterns"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|p| p.as_str().map(Arc::from))
|
||||
.collect();
|
||||
let layers: Vec<BannerLayer> = c["resultLayers"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|l| BannerLayer::new(l[0].as_str().unwrap_or(""), l[1].as_str().unwrap_or("")))
|
||||
.collect();
|
||||
let base = c["result"]
|
||||
.as_str()
|
||||
.and_then(banner_color)
|
||||
.unwrap_or("white");
|
||||
let dye = c["dyeStack"]
|
||||
.as_str()
|
||||
.and_then(|d| d.strip_prefix("minecraft:")?.strip_suffix("_dye"))
|
||||
.unwrap_or("white");
|
||||
let view = LoomView {
|
||||
width: int("width"),
|
||||
height: int("height"),
|
||||
left: int("left"),
|
||||
top: int("top"),
|
||||
has_banner: c["banner"].is_string(),
|
||||
has_dye: c["dye"].is_string(),
|
||||
has_pattern: c["pattern"].is_string(),
|
||||
patterns: &patterns,
|
||||
selected: int("selected"),
|
||||
display: c["display"].as_bool().unwrap_or(false),
|
||||
has_max: c["hasMax"].as_bool().unwrap_or(false),
|
||||
scroll: c["scroll"].as_f64().unwrap_or(0.0) as f32,
|
||||
start_row: int("startRow") as usize,
|
||||
result: c.get("resultLayers").map(|_| (base, layers.as_slice())),
|
||||
dye,
|
||||
mouse: (int("mouseX") as f32, int("mouseY") as f32),
|
||||
};
|
||||
screen_layout::loom(&view, &banners)
|
||||
} else {
|
||||
let mut disabled = [false; 9];
|
||||
for (i, d) in c["disabled"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.take(9)
|
||||
{
|
||||
disabled[i] = d.as_bool().unwrap_or(false);
|
||||
}
|
||||
screen_layout::crafter(&CrafterView {
|
||||
width: int("width"),
|
||||
height: int("height"),
|
||||
left: int("left"),
|
||||
top: int("top"),
|
||||
disabled,
|
||||
powered: c["powered"].as_bool().unwrap_or(false),
|
||||
})
|
||||
};
|
||||
let list: Vec<Value> = draws.iter().map(draw_json).collect();
|
||||
out += &json!({ "draws": list }).to_string();
|
||||
out.push('\n');
|
||||
}
|
||||
fs::write(args.get(3).ok_or(usage)?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! The Rust side of tools/test_server_files_parity.py: reads each case
|
||||
//! directory the way VanillaServerFilesOracle does (server.properties,
|
||||
//! eula.txt, the four user lists and scripted list changes) and prints the
|
||||
//! same report. `server_files_probe CASES OUTPUT`.
|
||||
use horizon_client::server::{
|
||||
eula,
|
||||
java_properties::now_millis,
|
||||
properties::{DIFFICULTIES, GAME_MODES, ServerProperties, SystemEntropy},
|
||||
user_lists::{BanEntry, BanList, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry, WhiteList},
|
||||
};
|
||||
use horizon_protocol::crypto::uuid_string;
|
||||
use std::{fmt::Write as _, path::Path};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let root = std::path::PathBuf::from(args.next().ok_or("CASES OUTPUT")?);
|
||||
let output = args.next().ok_or("OUTPUT")?;
|
||||
let mut cases: Vec<_> = std::fs::read_dir(&root)?
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
cases.sort();
|
||||
let mut out = String::new();
|
||||
for dir in cases {
|
||||
writeln!(out, "== {}", dir.file_name().unwrap().to_string_lossy())?;
|
||||
run_case(&dir, &mut out)?;
|
||||
}
|
||||
std::fs::write(output, out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files.readAllLines: split on \n, \r or \r\n.
|
||||
fn lines(text: &str) -> Vec<String> {
|
||||
let mut v: Vec<String> = text
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', "\n")
|
||||
.split('\n')
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
if v.last().is_some_and(String::is_empty) {
|
||||
v.pop();
|
||||
}
|
||||
v
|
||||
}
|
||||
fn latin1(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|&b| char::from(b)).collect()
|
||||
}
|
||||
fn has_key(text: &str, key: &str) -> bool {
|
||||
text.split('\n')
|
||||
.any(|l| l.trim_matches(|c: char| c <= ' ').starts_with(key))
|
||||
}
|
||||
|
||||
fn work(dir: &Path) -> std::io::Result<std::path::PathBuf> {
|
||||
let w = std::env::temp_dir().join(format!(
|
||||
"server-files-{}-{}",
|
||||
std::process::id(),
|
||||
dir.file_name().unwrap().to_string_lossy()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&w);
|
||||
std::fs::create_dir_all(&w)?;
|
||||
for e in std::fs::read_dir(dir)? {
|
||||
let e = e?;
|
||||
std::fs::copy(e.path(), w.join(e.file_name()))?;
|
||||
}
|
||||
Ok(w)
|
||||
}
|
||||
|
||||
fn run_case(dir: &Path, out: &mut String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let w = work(dir)?;
|
||||
let props = w.join("server.properties");
|
||||
if dir.join("server.properties").exists() || dir.join("properties.default").exists() {
|
||||
let before = std::fs::read(&props)
|
||||
.map(|b| latin1(&b))
|
||||
.unwrap_or_default();
|
||||
match ServerProperties::from_file(&props, &mut SystemEntropy::default()) {
|
||||
Ok(p) => {
|
||||
print_properties(&p, &before, out)?;
|
||||
p.store(&props)?;
|
||||
writeln!(out, "stored:")?;
|
||||
for (i, line) in lines(&std::fs::read_to_string(&props)?)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let line = if i == 1 && line.starts_with('#') {
|
||||
"#DATE".to_owned()
|
||||
} else if line.starts_with("management-server-secret=")
|
||||
&& !has_key(&before, "management-server-secret")
|
||||
{
|
||||
"management-server-secret=<random>".to_owned()
|
||||
} else {
|
||||
line
|
||||
};
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let class = e.split(':').next().unwrap_or("");
|
||||
writeln!(out, "ERROR {class}")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if dir.join("eula.txt").exists() || dir.join("eula.missing").exists() {
|
||||
let path = w.join("eula.txt");
|
||||
let agreed = eula::agreed(&path).unwrap_or(false);
|
||||
writeln!(out, "eula agreed={agreed}")?;
|
||||
if let Ok(bytes) = std::fs::read(&path) {
|
||||
for (i, line) in lines(&latin1(&bytes)).into_iter().enumerate() {
|
||||
if i == 1 && line.starts_with('#') {
|
||||
writeln!(out, "#DATE")?;
|
||||
} else {
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ops = OpList::new(w.join("ops.json"));
|
||||
let mut white = WhiteList::new(w.join("whitelist.json"));
|
||||
let mut bans = BanList::new(w.join("banned-players.json"));
|
||||
let mut ips = IpBanList::new(w.join("banned-ips.json"));
|
||||
let mut any = false;
|
||||
macro_rules! load {
|
||||
($list:expr, $name:literal) => {
|
||||
if $list.file.exists() {
|
||||
any = true;
|
||||
if let Err(_) = $list.load() {
|
||||
writeln!(
|
||||
out,
|
||||
"{} load ERROR com.google.gson.JsonSyntaxException",
|
||||
$name
|
||||
)?;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
load!(ops, "ops");
|
||||
load!(white, "whitelist");
|
||||
load!(bans, "banned-players");
|
||||
load!(ips, "banned-ips");
|
||||
if let Ok(actions) = std::fs::read_to_string(dir.join("actions.txt")) {
|
||||
any = true;
|
||||
for line in actions.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let a: Vec<&str> = line.split('\t').collect();
|
||||
let user = |n: &str, u: &str| NameAndId {
|
||||
id: horizon_client::server::properties::parse_uuid(u).unwrap(),
|
||||
name: n.to_owned(),
|
||||
};
|
||||
fn ban<T>(
|
||||
user: T,
|
||||
created: &str,
|
||||
source: &str,
|
||||
expires: &str,
|
||||
reason: &str,
|
||||
) -> BanEntry<T> {
|
||||
let date = |s: &str| (s != "-").then(|| s.parse::<i64>().unwrap());
|
||||
BanEntry {
|
||||
user,
|
||||
created: date(created).unwrap_or_else(now_millis),
|
||||
source: if source != "-" {
|
||||
source.to_owned()
|
||||
} else {
|
||||
"(Unknown)".into()
|
||||
},
|
||||
expires: date(expires),
|
||||
reason: (reason != "-").then(|| reason.to_owned()),
|
||||
}
|
||||
}
|
||||
let r = match a[0] {
|
||||
"op" => ops.add(OpEntry {
|
||||
user: user(a[1], a[2]),
|
||||
level: a[3].parse::<i32>()?.clamp(0, 4),
|
||||
bypasses_player_limit: a[4].eq_ignore_ascii_case("true"),
|
||||
}),
|
||||
"deop" => ops.remove(&user(a[1], a[2])),
|
||||
"white" => white.add(WhiteEntry {
|
||||
user: user(a[1], a[2]),
|
||||
}),
|
||||
"unwhite" => white.remove(&user(a[1], a[2])),
|
||||
"ban" => bans.add(ban(user(a[1], a[2]), a[3], a[4], a[5], a[6])),
|
||||
"pardon" => bans.remove(&user(a[1], a[2])),
|
||||
"banip" => ips.add(ban(a[1].to_owned(), a[2], a[3], a[4], a[5])),
|
||||
"pardonip" => ips.remove(&a[1].to_owned()),
|
||||
"isbanned" => bans.contains(&user(a[1], a[2])),
|
||||
"isop" => ops.get(&user(a[1], a[2])).is_some(),
|
||||
other => return Err(format!("unknown action {other}").into()),
|
||||
};
|
||||
writeln!(out, "action {} {r}", a[0])?;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
for (name, json) in [
|
||||
("ops", ops.to_json()),
|
||||
("whitelist", white.to_json()),
|
||||
("banned-players", bans.to_json()),
|
||||
("banned-ips", ips.to_json()),
|
||||
] {
|
||||
writeln!(out, "{name}:\n{json}")?;
|
||||
}
|
||||
let mut names: Vec<String> = ops.entries().iter().map(|e| e.user.name.clone()).collect();
|
||||
names.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
|
||||
writeln!(out, "op users {}", names.join(","))?;
|
||||
let mut names: Vec<String> = white
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|e| e.user.name.clone())
|
||||
.collect();
|
||||
names.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
|
||||
writeln!(out, "white users {}", names.join(","))?;
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&w);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_properties(p: &ServerProperties, before: &str, out: &mut String) -> std::fmt::Result {
|
||||
let secret = if has_key(before, "management-server-secret") {
|
||||
p.management_server_secret.clone()
|
||||
} else {
|
||||
"<random>".into()
|
||||
};
|
||||
let pack = p.resource_pack.as_ref().map_or_else(
|
||||
|| "none".to_owned(),
|
||||
|i| {
|
||||
format!(
|
||||
"{} {} {} {} {}",
|
||||
uuid_string(i.id),
|
||||
i.url,
|
||||
i.hash,
|
||||
i.required,
|
||||
i.prompt.is_some()
|
||||
)
|
||||
},
|
||||
);
|
||||
let opt_bool = |b: Option<bool>| b.map_or_else(|| "null".to_owned(), |b| b.to_string());
|
||||
let rows: Vec<(&str, String)> = vec![
|
||||
("online-mode", p.online_mode.to_string()),
|
||||
(
|
||||
"prevent-proxy-connections",
|
||||
p.prevent_proxy_connections.to_string(),
|
||||
),
|
||||
("server-ip", p.server_ip.clone()),
|
||||
("allow-flight", p.allow_flight.to_string()),
|
||||
("motd", p.motd.clone()),
|
||||
("enable-code-of-conduct", p.code_of_conduct.to_string()),
|
||||
("bug-report-link", p.bug_report_link.clone()),
|
||||
("force-gamemode", p.force_game_mode.to_string()),
|
||||
("enforce-whitelist", p.enforce_whitelist.to_string()),
|
||||
("difficulty", DIFFICULTIES[p.difficulty as usize].to_owned()),
|
||||
("gamemode", GAME_MODES[p.game_mode as usize].to_owned()),
|
||||
("level-name", p.level_name.clone()),
|
||||
("server-port", p.server_port.to_string()),
|
||||
(
|
||||
"management-server-enabled",
|
||||
p.management_server_enabled.to_string(),
|
||||
),
|
||||
("management-server-host", p.management_server_host.clone()),
|
||||
(
|
||||
"management-server-port",
|
||||
p.management_server_port.to_string(),
|
||||
),
|
||||
("management-server-secret", secret),
|
||||
(
|
||||
"management-server-tls-enabled",
|
||||
p.management_server_tls_enabled.to_string(),
|
||||
),
|
||||
(
|
||||
"management-server-tls-keystore",
|
||||
p.management_server_tls_keystore.clone(),
|
||||
),
|
||||
(
|
||||
"management-server-tls-keystore-password",
|
||||
p.management_server_tls_keystore_password.clone(),
|
||||
),
|
||||
(
|
||||
"management-server-allowed-origins",
|
||||
p.management_server_allowed_origins.clone(),
|
||||
),
|
||||
(
|
||||
"announce-player-achievements",
|
||||
opt_bool(p.announce_player_achievements),
|
||||
),
|
||||
("enable-query", p.enable_query.to_string()),
|
||||
("query.port", p.query_port.to_string()),
|
||||
("enable-rcon", p.enable_rcon.to_string()),
|
||||
("rcon.port", p.rcon_port.to_string()),
|
||||
("rcon.password", p.rcon_password.clone()),
|
||||
("hardcore", p.hardcore.to_string()),
|
||||
("use-native-transport", p.use_native_transport.to_string()),
|
||||
("spawn-protection", p.spawn_protection.to_string()),
|
||||
("op-permission-level", p.op_permission_level.to_string()),
|
||||
(
|
||||
"function-permission-level",
|
||||
p.function_permission_level.to_string(),
|
||||
),
|
||||
("max-tick-time", p.max_tick_time.to_string()),
|
||||
(
|
||||
"max-chained-neighbor-updates",
|
||||
p.max_chained_neighbor_updates.to_string(),
|
||||
),
|
||||
("rate-limit", p.rate_limit.to_string()),
|
||||
(
|
||||
"command-spam-threshold-seconds",
|
||||
p.command_spam_threshold_seconds.to_string(),
|
||||
),
|
||||
(
|
||||
"chat-spam-threshold-seconds",
|
||||
p.chat_spam_threshold_seconds.to_string(),
|
||||
),
|
||||
("view-distance", p.view_distance.to_string()),
|
||||
("simulation-distance", p.simulation_distance.to_string()),
|
||||
("max-players", p.max_players.to_string()),
|
||||
(
|
||||
"network-compression-threshold",
|
||||
p.network_compression_threshold.to_string(),
|
||||
),
|
||||
("broadcast-rcon-to-ops", p.broadcast_rcon_to_ops.to_string()),
|
||||
(
|
||||
"broadcast-console-to-ops",
|
||||
p.broadcast_console_to_ops.to_string(),
|
||||
),
|
||||
("max-world-size", p.max_world_size.to_string()),
|
||||
("sync-chunk-writes", p.sync_chunk_writes.to_string()),
|
||||
("region-file-compression", p.region_file_compression.clone()),
|
||||
("enable-jmx-monitoring", p.enable_jmx_monitoring.to_string()),
|
||||
("enable-status", p.enable_status.to_string()),
|
||||
("hide-online-players", p.hide_online_players.to_string()),
|
||||
(
|
||||
"entity-broadcast-range-percentage",
|
||||
p.entity_broadcast_range_percentage.to_string(),
|
||||
),
|
||||
("text-filtering-config", p.text_filtering_config.clone()),
|
||||
(
|
||||
"text-filtering-version",
|
||||
p.text_filtering_version.to_string(),
|
||||
),
|
||||
("player-idle-timeout", p.player_idle_timeout.to_string()),
|
||||
(
|
||||
"status-heartbeat-interval",
|
||||
p.status_heartbeat_interval.to_string(),
|
||||
),
|
||||
("white-list", p.white_list.to_string()),
|
||||
(
|
||||
"enforce-secure-profile",
|
||||
p.enforce_secure_profile.to_string(),
|
||||
),
|
||||
("log-ips", p.log_ips.to_string()),
|
||||
(
|
||||
"pause-when-empty-seconds",
|
||||
p.pause_when_empty_seconds.to_string(),
|
||||
),
|
||||
("accepts-transfers", p.accepts_transfers.to_string()),
|
||||
(
|
||||
"seed",
|
||||
if p.random_seed {
|
||||
"<random>".into()
|
||||
} else {
|
||||
p.seed.to_string()
|
||||
},
|
||||
),
|
||||
("generate-structures", p.generate_structures.to_string()),
|
||||
("resource-pack", pack),
|
||||
("initial-enabled-packs", p.initial_enabled_packs.join("|")),
|
||||
("initial-disabled-packs", p.initial_disabled_packs.join("|")),
|
||||
];
|
||||
for (k, v) in rows {
|
||||
writeln!(out, "{k}={v}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! `getSituationalMusic`/`getMusicVolume` through Horizon's `Situation` for
|
||||
//! `tools/test_situational_music_parity.py`:
|
||||
//! `situational_music_fixture ROOT INPUT OUTPUT`.
|
||||
use horizon_client::music::{BackgroundMusic, Music, MusicTables, Situation};
|
||||
use horizon_client::sound_events::normalize;
|
||||
use std::io::Write;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let tables = MusicTables::load(std::path::Path::new(&args[1]));
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let in_world = f[1] == "1";
|
||||
let (background, probe_volume) = if in_world {
|
||||
(tables.background(f[2], f[3]), tables.volume(f[2], f[3]))
|
||||
} else {
|
||||
(BackgroundMusic::default(), 1.0)
|
||||
};
|
||||
let situation = Situation {
|
||||
screen_music: (f[0] == "win").then(Music::credits),
|
||||
in_world,
|
||||
in_end: f[2] == "minecraft:the_end",
|
||||
boss_music: f[4].contains("music"),
|
||||
background: &background,
|
||||
probe_volume,
|
||||
creative: f[5] == "1" && f[6] == "1",
|
||||
underwater: f[7] == "1",
|
||||
};
|
||||
let music = situation.music().map_or("-".to_owned(), |m| {
|
||||
format!(
|
||||
"{},{},{},{}",
|
||||
normalize(&m.sound),
|
||||
m.min_delay,
|
||||
m.max_delay,
|
||||
m.replace_current_music
|
||||
)
|
||||
});
|
||||
writeln!(
|
||||
out,
|
||||
"{line} music={music} volume={:08x}",
|
||||
situation.volume().to_bits()
|
||||
)
|
||||
.expect("write");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Replays `tools/test_sound_engine_parity.py` scripts through Horizon's
|
||||
//! `SoundEngine` with a backend recording what an OpenAL source would hold:
|
||||
//! `sound_engine_fixture ROOT INPUT OUTPUT`.
|
||||
#[path = "audio_common/mod.rs"]
|
||||
mod common;
|
||||
use common::{Fake, d, describe, f, flush, result_name};
|
||||
use horizon_client::sound_engine::{
|
||||
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
|
||||
};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
|
||||
engine.listening = true;
|
||||
let mut backend = Fake::default();
|
||||
let mut ctx = TickContext::default();
|
||||
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
writeln!(out, "{line}").expect("write");
|
||||
let a: Vec<&str> = line.split(' ').collect();
|
||||
let source = |i: usize| a[i].parse::<usize>().expect("source");
|
||||
match a[0] {
|
||||
"play" => {
|
||||
let event = horizon_client::sound_events::normalize(a[3]);
|
||||
let random = LegacyRandom::new(a[14].parse().expect("seed"));
|
||||
let instance = match a[2] {
|
||||
"simple" => SoundInstance::new(
|
||||
&event,
|
||||
source(4),
|
||||
f(a[5]),
|
||||
f(a[6]),
|
||||
random,
|
||||
a[10] == "1",
|
||||
a[11].parse().expect("delay"),
|
||||
if a[12] == "linear" {
|
||||
Attenuation::Linear
|
||||
} else {
|
||||
Attenuation::None
|
||||
},
|
||||
[d(a[7]), d(a[8]), d(a[9])],
|
||||
a[13] == "1",
|
||||
),
|
||||
"loop" => SoundInstance::biome_loop(&event, random),
|
||||
"uloop" => SoundInstance::under_liquid_loop(&event, random),
|
||||
"usub" => SoundInstance::under_liquid_sub(&event, random),
|
||||
_ => panic!("{line}"),
|
||||
};
|
||||
if let Some(old) = instances.remove(a[1]) {
|
||||
engine.release(old);
|
||||
}
|
||||
let (id, result) = engine.play_new(instance, true, &mut backend);
|
||||
instances.insert(a[1].to_owned(), id);
|
||||
writeln!(out, " result {}", result_name(result)).expect("write");
|
||||
}
|
||||
"replay" => {
|
||||
let result = engine.play(instances[a[1]], &mut backend);
|
||||
writeln!(out, " result {}", result_name(result)).expect("write");
|
||||
}
|
||||
"queue" => engine.queue_ticking(instances[a[1]]),
|
||||
"delayed" => engine.play_delayed(instances[a[1]], a[2].parse().expect("delay")),
|
||||
"fade" => {
|
||||
let i = engine.instance_mut(instances[a[1]]).expect("instance");
|
||||
if a[2] == "in" {
|
||||
i.fade_in();
|
||||
} else {
|
||||
i.fade_out();
|
||||
}
|
||||
}
|
||||
"stop" => engine.stop(instances[a[1]], &mut backend),
|
||||
"stopmatch" => {
|
||||
let sound = (a[1] != "-").then(|| horizon_client::sound_events::normalize(a[1]));
|
||||
let src = (a[2] != "-").then(|| source(2));
|
||||
engine.stop_matching(sound.as_deref(), src, &mut backend);
|
||||
}
|
||||
"finish" => {
|
||||
if let Some(c) = engine.channel(instances[a[1]]) {
|
||||
backend.stop(c);
|
||||
}
|
||||
}
|
||||
"gain" => engine.update_category_volume(source(1), f(a[2]), &mut backend),
|
||||
"volume" => engine.source_volumes[source(1)] = f(a[2]),
|
||||
"refresh" => engine.refresh_category_volume(source(1), &mut backend),
|
||||
"pause" => engine.pause_all_except(&[1, 10], &mut backend),
|
||||
"resume" => engine.resume(&mut backend),
|
||||
"player" => {
|
||||
ctx.player_underwater = a[1] == "1";
|
||||
ctx.player_removed = a[2] == "1";
|
||||
}
|
||||
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
|
||||
"forget" => {
|
||||
if let Some(id) = instances.remove(a[1]) {
|
||||
engine.release(id);
|
||||
}
|
||||
}
|
||||
_ => panic!("{line}"),
|
||||
}
|
||||
for (name, id) in &instances {
|
||||
writeln!(out, "{}", describe(&engine, &backend, name, *id)).expect("write");
|
||||
}
|
||||
flush(&mut engine, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Resolves sound instances through Horizon's sound event registry for
|
||||
//! `tools/test_sound_event_parity.py`: `sound_event_fixture ROOT INPUT OUTPUT [NAMES]`.
|
||||
use horizon_client::sound_engine::{Attenuation, MASTER, SoundEngine, SoundInstance};
|
||||
use horizon_client::sound_events::{Sentinel, SoundRegistry};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::io::Write;
|
||||
|
||||
fn bits(v: f32) -> String {
|
||||
format!("{:08x}", v.to_bits())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let registry = SoundRegistry::load(std::path::Path::new(&args[1]));
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
let mut names: Vec<String> = engine.registry.names().cloned().collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
writeln!(out, "registered {name}").expect("write");
|
||||
}
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let seed: i64 = f[1].parse().expect("seed");
|
||||
let volume = f32::from_bits(u32::from_str_radix(f[2], 16).expect("volume"));
|
||||
let pitch = f32::from_bits(u32::from_str_radix(f[3], 16).expect("pitch"));
|
||||
let event = horizon_client::sound_events::normalize(f[0]);
|
||||
let instance = SoundInstance::new(
|
||||
&event,
|
||||
MASTER,
|
||||
volume,
|
||||
pitch,
|
||||
LegacyRandom::new(seed),
|
||||
false,
|
||||
0,
|
||||
Attenuation::Linear,
|
||||
[0.0; 3],
|
||||
false,
|
||||
);
|
||||
let id = engine.add(instance, true);
|
||||
engine.resolve(id);
|
||||
let weight = engine.registry.weight(&event);
|
||||
let i = engine.instance_mut(id).expect("instance");
|
||||
let mut s = line.to_owned();
|
||||
match &i.event {
|
||||
None => s.push_str(" event=none"),
|
||||
Some(subtitle) => s.push_str(&format!(
|
||||
" event weight={weight} subtitle={}",
|
||||
subtitle.as_deref().unwrap_or("-")
|
||||
)),
|
||||
}
|
||||
let v = i.get_volume();
|
||||
let p = i.get_pitch();
|
||||
let sound = i.sound.clone().expect("resolved");
|
||||
let (namespace, path) = sound.path();
|
||||
let sentinel = match sound.sentinel {
|
||||
Sentinel::None => "-",
|
||||
Sentinel::Empty => "empty",
|
||||
Sentinel::IntentionallyEmpty => "intentionally_empty",
|
||||
};
|
||||
s.push_str(&format!(
|
||||
" sound={} path={namespace}:{path} sentinel={sentinel} weight={} type={} stream={} preload={} attenuation={} volume={} pitch={} range={} next={}",
|
||||
sound.location,
|
||||
sound.weight,
|
||||
if sound.event { "SOUND_EVENT" } else { "FILE" },
|
||||
sound.stream,
|
||||
sound.preload,
|
||||
sound.attenuation_distance,
|
||||
bits(v),
|
||||
bits(p),
|
||||
bits(sound.attenuation_distance(v)),
|
||||
i.random.next_long()
|
||||
));
|
||||
writeln!(out, "{s}").expect("write");
|
||||
engine.release(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Spawn placement and spawn predicate answers for differential tests against
|
||||
//! vanilla (tools/test_spawn_rules_parity.py): reads the column-world cases of
|
||||
//! `VanillaSpawnRulesOracle` and prints `mobs::spawn_rules`' answers.
|
||||
//!
|
||||
//! usage: spawn_rules_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
items::Items,
|
||||
mobs::{
|
||||
MobTypes,
|
||||
data::MobData,
|
||||
spawn_rules::{self, Dimension, Reason, SpawnLevel},
|
||||
},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::{chunk::BlockState, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
fmt::Write as _,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
struct Column<'a> {
|
||||
facts: &'a BlockFacts,
|
||||
data: &'a MobData,
|
||||
block_tags: &'a HashMap<String, HashSet<String>>,
|
||||
air: BlockStateId,
|
||||
blocks: HashMap<i32, BlockStateId>,
|
||||
x0: i32,
|
||||
z0: i32,
|
||||
sky: i32,
|
||||
light: i32,
|
||||
darken: i32,
|
||||
thunder: bool,
|
||||
sea: i32,
|
||||
surface: i32,
|
||||
seed: i64,
|
||||
peaceful: bool,
|
||||
dimension: Dimension,
|
||||
biome: String,
|
||||
slime: f32,
|
||||
/// A player position and whether it is survival or adventure.
|
||||
player: Option<([f64; 3], bool)>,
|
||||
}
|
||||
impl SpawnLevel for Column<'_> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
|
||||
if x != self.x0 || z != self.z0 {
|
||||
return self.air;
|
||||
}
|
||||
self.blocks.get(&y).copied().unwrap_or(self.air)
|
||||
}
|
||||
fn facts(&self) -> &BlockFacts {
|
||||
self.facts
|
||||
}
|
||||
fn mob_data(&self) -> &MobData {
|
||||
self.data
|
||||
}
|
||||
fn block_tag(&self, tag: &str, block: &str) -> bool {
|
||||
self.block_tags.get(tag).is_some_and(|s| s.contains(block))
|
||||
}
|
||||
fn biome_tag(&self, tag: &str, _: i32, _: i32, _: i32) -> bool {
|
||||
self.data
|
||||
.biome_tags
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(&self.biome))
|
||||
}
|
||||
fn sky_light(&self, _: i32, _: i32, _: i32) -> i32 {
|
||||
self.sky
|
||||
}
|
||||
fn block_light(&self, _: i32, _: i32, _: i32) -> i32 {
|
||||
self.light
|
||||
}
|
||||
fn sky_darken(&self) -> i32 {
|
||||
self.darken
|
||||
}
|
||||
fn thundering(&self) -> bool {
|
||||
self.thunder
|
||||
}
|
||||
fn sea_level(&self) -> i32 {
|
||||
self.sea
|
||||
}
|
||||
fn world_surface(&self, _: i32, _: i32) -> i32 {
|
||||
self.surface
|
||||
}
|
||||
fn peaceful(&self) -> bool {
|
||||
self.peaceful
|
||||
}
|
||||
fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
|
||||
self.slime
|
||||
}
|
||||
fn world_seed(&self) -> i64 {
|
||||
self.seed
|
||||
}
|
||||
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
|
||||
self.player.is_some_and(|(p, targetable)| {
|
||||
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
|
||||
targetable && d < distance * distance
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `name[key=value,...]`.
|
||||
fn parse_state(spec: &str) -> BlockState {
|
||||
let (name, props) = match spec.split_once('[') {
|
||||
Some((n, rest)) => (n, rest.trim_end_matches(']')),
|
||||
None => (spec, ""),
|
||||
};
|
||||
let properties: BTreeMap<String, String> = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect();
|
||||
BlockState {
|
||||
name: name.to_owned(),
|
||||
properties,
|
||||
}
|
||||
}
|
||||
|
||||
fn reason(name: &str) -> Reason {
|
||||
match name {
|
||||
"chunk_generation" => Reason::ChunkGeneration,
|
||||
"spawner" => Reason::Spawner,
|
||||
"trial_spawner" => Reason::TrialSpawner,
|
||||
"reinforcement" => Reason::Reinforcement,
|
||||
"patrol" => Reason::Patrol,
|
||||
"structure" => Reason::Structure,
|
||||
"event" => Reason::Event,
|
||||
"command" => Reason::Command,
|
||||
_ => Reason::Natural,
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = Path::new(
|
||||
args.get(1)
|
||||
.ok_or("usage: spawn_rules_fixture RESOURCES INPUT OUTPUT")?,
|
||||
);
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let data = MobData::load(root, &facts);
|
||||
let items = Items::tags_only(root)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut c = BTreeMap::new();
|
||||
let mut name = "";
|
||||
for token in raw.split_whitespace() {
|
||||
match token.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
c.insert(k, v);
|
||||
}
|
||||
None => name = token,
|
||||
}
|
||||
}
|
||||
let get =
|
||||
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
|
||||
let Some(kind) = types.types.get(c["type"]) else {
|
||||
let _ = writeln!(out, "{name} error unsupported {}", c["type"]);
|
||||
continue;
|
||||
};
|
||||
let y: i32 = get("y", "64").parse()?;
|
||||
let (x0, z0): (i32, i32) = (get("x", "8").parse()?, get("z", "8").parse()?);
|
||||
let mut blocks = HashMap::new();
|
||||
for (k, v) in &c {
|
||||
if let Some(offset) = k.strip_prefix("at") {
|
||||
let offset: i32 = offset.parse()?;
|
||||
blocks.insert(y + offset, registry.resolve(&parse_state(v))?);
|
||||
}
|
||||
}
|
||||
let player = c.get("player").map(|p| {
|
||||
let f: Vec<&str> = p.split(',').collect();
|
||||
let at = |i: usize, base: i32| f64::from(base) + f[i].parse::<f64>().unwrap_or(0.0);
|
||||
let mode = f.get(3).copied().unwrap_or("survival");
|
||||
(
|
||||
[at(0, x0), at(1, y), at(2, z0)],
|
||||
mode == "survival" || mode == "adventure",
|
||||
)
|
||||
});
|
||||
let level = Column {
|
||||
facts: &facts,
|
||||
data: &data,
|
||||
block_tags: &items.block_tags,
|
||||
air: facts.air,
|
||||
blocks,
|
||||
x0,
|
||||
z0,
|
||||
sky: get("sky", "15").parse()?,
|
||||
light: get("light", "0").parse()?,
|
||||
darken: get("darken", "0").parse()?,
|
||||
thunder: get("thunder", "0") == "1",
|
||||
sea: get("sea", "63").parse()?,
|
||||
surface: get("surface", "0").parse()?,
|
||||
seed: get("wseed", "0").parse()?,
|
||||
peaceful: get("difficulty", "normal") == "peaceful",
|
||||
dimension: match get("dim", "minecraft:overworld").as_str() {
|
||||
"minecraft:the_nether" => Dimension::Nether,
|
||||
"minecraft:the_end" => Dimension::End,
|
||||
_ => Dimension::Overworld,
|
||||
},
|
||||
biome: get("biome", "minecraft:plains"),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
player,
|
||||
};
|
||||
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
|
||||
let s = kind.species;
|
||||
let ok = spawn_rules::is_spawn_position_ok(s, &level, x0, y, z0);
|
||||
let rules = spawn_rules::check_spawn_rules(
|
||||
s,
|
||||
&level,
|
||||
reason(&get("reason", "natural")),
|
||||
&mut random,
|
||||
x0,
|
||||
y,
|
||||
z0,
|
||||
);
|
||||
let next = random.next_int_bound(1000).unwrap_or(0);
|
||||
let _ = writeln!(out, "{name} {ok} {rules} {next}");
|
||||
}
|
||||
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! The subtitle overlay for differential tests against vanilla's
|
||||
//! `SubtitleOverlay` (tools/test_subtitle_parity.py): replays each case's
|
||||
//! sound plays, clock changes and frames through
|
||||
//! `horizon_client::subtitles` and prints every frame's fills and texts with
|
||||
//! their pose, in VanillaSubtitleOracle's JSON lines.
|
||||
//!
|
||||
//! usage: subtitle_fixture RESOURCES INPUTS OUTPUT
|
||||
use horizon_client::{
|
||||
hud::HudAtlas,
|
||||
hud_layout::{GuiDraw, IDENTITY, background_color, pose_apply},
|
||||
hud_overlays::{SubtitleFrame, SubtitleOverlay},
|
||||
lang,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, io::Write, path::PathBuf};
|
||||
|
||||
fn vec3(v: &Value) -> [f64; 3] {
|
||||
std::array::from_fn(|i| v[i].as_f64().unwrap_or(0.0))
|
||||
}
|
||||
fn pose(m: [f32; 6]) -> Value {
|
||||
json!(m)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: subtitle_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
lang::set(lang::Language::load(&[root], lang::DEFAULT));
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
let mut out = std::io::BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let c: Value = serde_json::from_str(line)?;
|
||||
let mut overlay = SubtitleOverlay::default();
|
||||
let mut now = 0i64;
|
||||
let mut frames = Vec::new();
|
||||
for op in c["ops"].as_array().into_iter().flatten() {
|
||||
match op["op"].as_str().unwrap_or_default() {
|
||||
// Util.getMillis: nanoseconds / 1_000_000.
|
||||
"time" => now = op["ms"].as_i64().unwrap_or(0),
|
||||
"play" => {
|
||||
if overlay.listening() {
|
||||
let range = op["range"].as_f64().map_or(f32::INFINITY, |r| r as f32);
|
||||
overlay.on_play(
|
||||
op["key"].as_str(),
|
||||
vec3(&op["at"]),
|
||||
range,
|
||||
op["looping"].as_bool().unwrap_or(false),
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
"frame" => {
|
||||
let frame = SubtitleFrame {
|
||||
show: op["show"].as_bool().unwrap_or(false),
|
||||
position: vec3(&op["pos"]),
|
||||
forward: vec3(&op["forward"]),
|
||||
up: vec3(&op["up"]),
|
||||
multiplier: op["mult"].as_f64().unwrap_or(1.0),
|
||||
background: background_color(
|
||||
op["chat_only"].as_bool().unwrap_or(true),
|
||||
op["opacity"].as_f64().unwrap_or(0.5),
|
||||
0.8,
|
||||
),
|
||||
width: op["w"].as_i64().unwrap_or(0) as i32,
|
||||
height: op["h"].as_i64().unwrap_or(0) as i32,
|
||||
};
|
||||
let mut stack = vec![IDENTITY];
|
||||
let mut draws = Vec::new();
|
||||
let calls = overlay.extract(&frame, &atlas.font, now);
|
||||
if !calls.is_empty() {
|
||||
draws.push(json!({"type": "stratum"}));
|
||||
}
|
||||
for d in calls {
|
||||
let m = *stack.last().unwrap_or(&IDENTITY);
|
||||
match d {
|
||||
GuiDraw::Push(ops) => {
|
||||
let mut top = m;
|
||||
for op in ops {
|
||||
pose_apply(&mut top, op);
|
||||
}
|
||||
stack.push(top);
|
||||
}
|
||||
GuiDraw::Pop => {
|
||||
stack.pop();
|
||||
}
|
||||
GuiDraw::Fill {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
color,
|
||||
} => draws.push(json!({
|
||||
"type": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1,
|
||||
"color": format!("{color:x}"), "pose": pose(m),
|
||||
})),
|
||||
GuiDraw::Text {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
shadow,
|
||||
} => draws.push(json!({
|
||||
"type": "text", "text": text.plain(), "x": x, "y": y,
|
||||
"color": format!("{color:x}"), "shadow": shadow, "pose": pose(m),
|
||||
})),
|
||||
other => return Err(format!("unexpected draw {other:?}").into()),
|
||||
}
|
||||
}
|
||||
frames.push(Value::Array(draws));
|
||||
}
|
||||
other => return Err(format!("op {other}").into()),
|
||||
}
|
||||
}
|
||||
writeln!(out, "{}", json!({"case": c["case"], "frames": frames}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Headless transport for the player list parity gate: builds each case's
|
||||
//! scoreboard (teams, `list` objective, scores), listed players, header and
|
||||
//! footer, replays its frames through `horizon_client::tab_list` and writes
|
||||
//! the draw calls in vanilla's GuiGraphicsExtractor terms (text as styled
|
||||
//! runs, faces as skin blits).
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
component,
|
||||
hud::HudAtlas,
|
||||
hud_layout::GuiDraw,
|
||||
lang,
|
||||
scoreboard::{NumberFormat, RenderType, Scoreboard},
|
||||
tab_list::{self, TabInputs, TabList, TabPlayer},
|
||||
text::Text,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn text(v: &Value) -> Option<Text> {
|
||||
(!v.is_null()).then(|| component::to_text(v, &mut |_| Text::empty()))
|
||||
}
|
||||
fn format(v: &Value) -> Option<NumberFormat> {
|
||||
match v["type"].as_str()? {
|
||||
"blank" => Some(NumberFormat::Blank),
|
||||
"styled" => Some(NumberFormat::Styled(v["style"].clone())),
|
||||
"fixed" => Some(NumberFormat::Fixed(v["value"].clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn runs(t: &Text) -> Value {
|
||||
let mut out: Vec<(String, Value)> = Vec::new();
|
||||
for (c, s) in t.chars() {
|
||||
let key = json!([
|
||||
s.color.map_or(-1, i64::from),
|
||||
s.is_bold(),
|
||||
s.is_italic(),
|
||||
s.underlined == Some(true),
|
||||
s.strikethrough == Some(true),
|
||||
s.obfuscated == Some(true)
|
||||
]);
|
||||
match out.last_mut() {
|
||||
Some((run, k)) if *k == key => run.push(c),
|
||||
_ => out.push((c.to_string(), key)),
|
||||
}
|
||||
}
|
||||
out.into_iter().map(|(r, k)| json!([r, k])).collect()
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||
let [root, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: tab_list_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
let atlas = HudAtlas::build(root)?;
|
||||
lang::set(lang::Language::load(&[root], "en_us"));
|
||||
let font = &atlas.font;
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let c: Value = serde_json::from_str(line)?;
|
||||
let mut board = Scoreboard::default();
|
||||
for t in c["teams"].as_array().into_iter().flatten() {
|
||||
let name = t["name"].as_str().unwrap_or("");
|
||||
let team = board.add_team(name);
|
||||
team.color = t["color"].as_str().map(str::to_owned);
|
||||
team.prefix = t["prefix"].clone();
|
||||
team.suffix = t["suffix"].clone();
|
||||
for m in t["members"].as_array().into_iter().flatten() {
|
||||
board.add_to_team(m.as_str().unwrap_or(""), name);
|
||||
}
|
||||
}
|
||||
let objective_name = c["objective"]["name"].as_str().map(str::to_owned);
|
||||
if let Some(name) = &objective_name {
|
||||
let hearts = c["objective"]["renderType"] == "hearts";
|
||||
board.add_objective(
|
||||
name,
|
||||
"dummy",
|
||||
Value::String("x".into()),
|
||||
if hearts {
|
||||
RenderType::Hearts
|
||||
} else {
|
||||
RenderType::Integer
|
||||
},
|
||||
false,
|
||||
format(&c["objective"]["numberFormat"]),
|
||||
);
|
||||
}
|
||||
let mut players = Vec::new();
|
||||
let mut upside = Vec::new();
|
||||
for p in c["players"].as_array().into_iter().flatten() {
|
||||
let uuid =
|
||||
u128::from_str_radix(&p["uuid"].as_str().unwrap_or("0").replace('-', ""), 16)?;
|
||||
let name = p["name"].as_str().unwrap_or("").to_owned();
|
||||
if p["inLevel"].as_bool().unwrap_or(false) && (name == "Dinnerbone" || name == "Grumm")
|
||||
{
|
||||
upside.push(uuid);
|
||||
}
|
||||
let mut player = TabPlayer::new(uuid, name);
|
||||
player.game_mode = p["gameMode"].as_i64().unwrap_or(0) as i32;
|
||||
player.latency = p["latency"].as_i64().unwrap_or(0) as i32;
|
||||
player.display_name = (!p["displayName"].is_null()).then(|| p["displayName"].clone());
|
||||
player.list_order = p["listOrder"].as_i64().unwrap_or(0) as i32;
|
||||
player.show_hat = p["showHat"].as_bool().unwrap_or(true);
|
||||
players.push(player);
|
||||
}
|
||||
let mut list = TabList::default();
|
||||
list.header = text(&c["header"]);
|
||||
list.footer = text(&c["footer"]);
|
||||
let background = tab_list::row_background(
|
||||
c["backgroundForChatOnly"].as_bool().unwrap_or(true),
|
||||
c["opacity"].as_f64().unwrap_or(0.5),
|
||||
);
|
||||
let mut frames = Vec::new();
|
||||
for f in c["frames"].as_array().into_iter().flatten() {
|
||||
if let Some(obj) = &objective_name {
|
||||
for (holder, v) in f["scores"].as_object().into_iter().flatten() {
|
||||
let s = board.score_mut(holder, obj);
|
||||
s.value = v["value"].as_i64().unwrap_or(0) as i32;
|
||||
s.number_format = format(&v["format"]);
|
||||
}
|
||||
for r in f["reset"].as_array().into_iter().flatten() {
|
||||
board.reset_one(r.as_str().unwrap_or(""), obj);
|
||||
}
|
||||
}
|
||||
let visible = f["visible"].as_bool().unwrap_or(false);
|
||||
list.set_visible(visible);
|
||||
let mut draws = Vec::new();
|
||||
if visible {
|
||||
let up = |id: u128| upside.contains(&id);
|
||||
let inputs = TabInputs {
|
||||
players: &players,
|
||||
board: &board,
|
||||
objective: objective_name.as_deref().and_then(|n| board.objective(n)),
|
||||
screen_width: c["width"].as_i64().unwrap_or(0) as i32,
|
||||
online_mode: c["onlineMode"].as_bool().unwrap_or(false),
|
||||
gui_ticks: f["tick"].as_i64().unwrap_or(0),
|
||||
background,
|
||||
upside_down: &up,
|
||||
};
|
||||
for d in list.layout(&inputs, font) {
|
||||
draws.push(match d {
|
||||
GuiDraw::Fill { x0, y0, x1, y1, color } => {
|
||||
json!({"type": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": color as i32})
|
||||
}
|
||||
GuiDraw::Sprite { sprite, x, y, w, h, .. } => {
|
||||
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h})
|
||||
}
|
||||
GuiDraw::Text { text, x, y, color, shadow } => json!({
|
||||
"type": "text", "runs": runs(&text), "x": x, "y": y, "color": color as i32, "shadow": shadow
|
||||
}),
|
||||
GuiDraw::Blit { texture, x, y, w, h, uv, src, tex, color, .. } => {
|
||||
let a = [x as f64, y as f64, f64::from(uv[0]), f64::from(uv[1]), w as f64, h as f64,
|
||||
src[0] as f64, src[1] as f64, tex[0] as f64, tex[1] as f64];
|
||||
json!({"type": "blit", "texture": texture, "args": a, "color": color as i32})
|
||||
}
|
||||
other => json!({"type": "unexpected", "draw": format!("{other:?}")}),
|
||||
});
|
||||
}
|
||||
}
|
||||
frames.push(Value::Array(draws));
|
||||
}
|
||||
writeln!(out, "{}", json!({ "frames": frames }))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Team rules in gameplay for differential tests against vanilla
|
||||
//! (tools/test_team_rules_parity.py): friendly fire, alliances, collision
|
||||
//! rules, seeing invisible team mates and name tag visibility, from
|
||||
//! `horizon_client::scoreboard`, in VanillaTeamRulesOracle's JSON lines.
|
||||
//!
|
||||
//! usage: team_rules_fixture INPUTS OUTPUT
|
||||
use horizon_client::scoreboard::{self as sb, CollisionRule, NameView, Scoreboard, Visibility};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fs, io::Write};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut out = std::io::BufWriter::new(fs::File::create(&args[2]).expect("output"));
|
||||
for line in fs::read_to_string(&args[1]).expect("inputs").lines() {
|
||||
let c: Value = serde_json::from_str(line).expect("case");
|
||||
let mut board = Scoreboard::default();
|
||||
for name in ["A", "B"] {
|
||||
let o = &c[name];
|
||||
let t = board.add_team(name);
|
||||
t.allow_friendly_fire = o["ff"].as_bool().unwrap_or(true);
|
||||
t.see_friendly_invisibles = o["si"].as_bool().unwrap_or(true);
|
||||
t.collision_rule =
|
||||
CollisionRule::by_name(o["collision"].as_str().unwrap_or("")).expect("rule");
|
||||
t.nametag_visibility =
|
||||
Visibility::by_name(o["nametag"].as_str().unwrap_or("")).expect("visibility");
|
||||
}
|
||||
let team = |k: &str| c[k].as_str().and_then(|n| board.team(n));
|
||||
let flag = |k: &str| c[k].as_bool().unwrap_or(false);
|
||||
let (own, other, viewer) = (team("own"), team("other"), team("viewer"));
|
||||
let invisible =
|
||||
sb::invisible_to(other, viewer, flag("viewer_spectator"), flag("invisible"));
|
||||
let view = NameView {
|
||||
discrete: flag("discrete"),
|
||||
distance_sqr: c["distance"].as_f64().unwrap_or(0.0),
|
||||
invisible_to_viewer: invisible,
|
||||
hud_hidden: flag("hud_hidden"),
|
||||
is_camera: flag("camera_is_entity"),
|
||||
is_vehicle: flag("vehicle"),
|
||||
};
|
||||
let r = json!({
|
||||
"case": c["case"],
|
||||
"damage": sb::teams_allow_damage(own, other),
|
||||
"allied": sb::teams_allied(own, other),
|
||||
"pushes": !flag("spectator") && sb::pushable_by(own, other, flag("pushable")),
|
||||
"invisible": invisible,
|
||||
"name": sb::name_shown(other, viewer, view),
|
||||
});
|
||||
writeln!(out, "{r}").expect("write");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Runs tools/test_toggle_keys_parity.py's event sequences through
|
||||
//! `ToggleKeys`, the sneak/sprint/attack/use toggle mappings.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
keymapping::{KeyAction, ToggleKeys},
|
||||
};
|
||||
use serde_json::Value;
|
||||
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 (n, line) in input.lines().enumerate() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
writeln!(out, "== {n}")?;
|
||||
let mut keys = ToggleKeys::default();
|
||||
let mut options = [false; 4];
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
let name = op[0].as_str().unwrap();
|
||||
let slot = || op[1].as_u64().unwrap() as usize;
|
||||
match name {
|
||||
"option" => options[slot()] = op[2].as_bool().unwrap(),
|
||||
"key" => {
|
||||
let action = match op[2].as_i64().unwrap() {
|
||||
0 => KeyAction::Release,
|
||||
1 => KeyAction::Press,
|
||||
_ => KeyAction::Repeat,
|
||||
};
|
||||
keys.key(slot(), action, op[3].as_bool().unwrap(), options[slot()]);
|
||||
}
|
||||
"button" => keys.button(
|
||||
slot(),
|
||||
op[2].as_bool().unwrap(),
|
||||
op[3].as_bool().unwrap(),
|
||||
options[slot()],
|
||||
),
|
||||
"open" => keys.screen_opened(options),
|
||||
"close" => keys.screen_closed(options),
|
||||
"respawn" => keys.respawn(),
|
||||
other => panic!("{other}"),
|
||||
}
|
||||
let mut line = name.to_string();
|
||||
for k in &keys.0 {
|
||||
line += &format!(" {} {}", k.is_down, k.pending_clicks());
|
||||
}
|
||||
writeln!(out, "{line}")?;
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Headless transport for the world border parity gate: replays each case's
|
||||
//! border changes, ticks and frames through `horizon_client::worldborder`,
|
||||
//! `border_render` and `hud_overlays::vignette`, and writes what the
|
||||
//! vanilla renderer computes (bounds, fade, wall buffer, transforms, walls
|
||||
//! drawn, vignette colour) with floats as raw bits.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
border_render::{self, BorderRenderState, BorderRenderer},
|
||||
hud_layout::GuiDraw,
|
||||
hud_overlays,
|
||||
worldborder::{BorderStatus, WorldBorder},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufWriter, Write},
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn floats(v: &[f32]) -> Value {
|
||||
v.iter().map(|f| f.to_bits() as i32).collect()
|
||||
}
|
||||
fn doubles(v: &[f64]) -> Value {
|
||||
v.iter().map(|f| f.to_bits() as i64).collect()
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [inputs, output] = args.as_slice() else {
|
||||
return Err("usage: world_border_render_fixture INPUTS OUTPUT".into());
|
||||
};
|
||||
let mut out = BufWriter::new(fs::File::create(output)?);
|
||||
for line in fs::read_to_string(inputs)?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
{
|
||||
let c: Value = serde_json::from_str(line)?;
|
||||
let f = |v: &Value| v.as_f64().unwrap_or(0.0);
|
||||
let mut border = WorldBorder::default();
|
||||
border.warning_distance = c["warningBlocks"].as_i64().unwrap_or(0) as i32;
|
||||
border.warning_time = c["warningTime"].as_i64().unwrap_or(0) as i32;
|
||||
border.set_absolute_max_size(c["absoluteMaxSize"].as_i64().unwrap_or(0) as i32);
|
||||
border.set_center([f(&c["centerX"]), f(&c["centerZ"])]);
|
||||
border.set_size(f(&c["size"]));
|
||||
let mut renderer = BorderRenderer::default();
|
||||
let mut state = BorderRenderState::default();
|
||||
let mut frames = Vec::new();
|
||||
for op in c["ops"].as_array().into_iter().flatten() {
|
||||
match op["op"].as_str().unwrap_or("") {
|
||||
"tick" => (0..op["n"].as_i64().unwrap_or(0)).for_each(|_| border.tick()),
|
||||
"lerp" => border.lerp_between(
|
||||
f(&op["from"]),
|
||||
f(&op["to"]),
|
||||
op["ticks"].as_i64().unwrap_or(0),
|
||||
),
|
||||
"size" => border.set_size(f(&op["size"])),
|
||||
"center" => border.set_center([f(&op["x"]), f(&op["z"])]),
|
||||
"warning" => {
|
||||
border.warning_distance = op["blocks"].as_i64().unwrap_or(0) as i32;
|
||||
border.warning_time = op["time"].as_i64().unwrap_or(0) as i32;
|
||||
}
|
||||
"invalidate" => renderer.invalidate(),
|
||||
"frame" => {
|
||||
let partial = f(&op["partial"]) as f32;
|
||||
let p = &op["camera"];
|
||||
let camera = [f(&p[0]), f(&p[1]), f(&p[2])];
|
||||
let distance = f(&op["renderDistance"]);
|
||||
let millis = op["millis"].as_i64().unwrap_or(0);
|
||||
let status = match border.status() {
|
||||
BorderStatus::Growing => "GROWING",
|
||||
BorderStatus::Shrinking => "SHRINKING",
|
||||
BorderStatus::Stationary => "STATIONARY",
|
||||
};
|
||||
let mut frame = json!({
|
||||
"bounds": doubles(&border.bounds_at(partial)),
|
||||
"sizes": doubles(&[border.size(), border.target_size(), border.lerp_speed(), border.distance(camera[0], camera[2])]),
|
||||
"lerpTime": border.remaining_ticks(),
|
||||
"status": status,
|
||||
});
|
||||
border_render::extract(&border, partial, camera, distance, &mut state);
|
||||
frame["alpha"] = doubles(&[state.alpha]);
|
||||
frame["tint"] = json!(state.tint as i32);
|
||||
let drawn =
|
||||
renderer.prepare(&state, camera, distance, f(&op["depthFar"]) as f32);
|
||||
frame["drawn"] = json!(drawn);
|
||||
frame["rebuilt"] = json!(renderer.take_rebuilt());
|
||||
if drawn {
|
||||
frame["vertices"] = floats(&renderer.vertices.concat());
|
||||
let t = renderer.transforms(&state, camera, millis);
|
||||
frame["color"] = floats(&t.color);
|
||||
frame["offset"] = floats(&t.model_offset);
|
||||
frame["texture"] = floats(&[t.texture_offset, t.texture_offset]);
|
||||
frame["sides"] = json!(BorderRenderer::sides(&state, camera, distance));
|
||||
}
|
||||
let warning = border.warning_strength(camera[0], camera[2]);
|
||||
let vignette =
|
||||
hud_overlays::vignette(427, 240, f(&op["vignette"]) as f32, warning);
|
||||
let color = match vignette {
|
||||
GuiDraw::Blit { color, .. } => color,
|
||||
_ => 0,
|
||||
};
|
||||
frame["vignetteColor"] = json!(color as i32);
|
||||
frames.push(frame);
|
||||
}
|
||||
other => return Err(format!("unknown op {other}").into()),
|
||||
}
|
||||
}
|
||||
writeln!(out, "{}", json!({ "frames": frames }))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! The player's ambient sound handlers: `BiomeAmbientSoundsHandler` (biome
|
||||
//! loops with 40-tick cross-fades, random additions, cave mood) driven by the
|
||||
//! `minecraft:audio/ambient_sounds` attribute, `UnderwaterAmbientSoundHandler`
|
||||
//! and the underwater loop (`UnderLiquidAmbientSoundInstance`).
|
||||
//! and `LocalPlayer.updateIsUnderwater` (enter/exit sounds and the
|
||||
//! `UnderLiquidAmbientSoundInstance` loop), played through the sound engine.
|
||||
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
@@ -80,87 +82,83 @@ impl AmbientTables {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
/// The sound engine side of the handlers.
|
||||
pub trait AmbientOutput {
|
||||
/// A relative tickable instance; None when it could not start.
|
||||
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64>;
|
||||
fn set_volume(&mut self, id: u64, volume: f32);
|
||||
fn stop(&mut self, id: u64);
|
||||
fn is_active(&self, id: u64) -> bool;
|
||||
/// A positional one-shot (`SimpleSoundInstance`, linear attenuation).
|
||||
fn play_at(&mut self, event: &str, at: [f64; 3]);
|
||||
}
|
||||
impl AmbientOutput for crate::audio::Audio {
|
||||
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
|
||||
crate::audio::Audio::play_tracked(self, event, volume, looping)
|
||||
}
|
||||
fn set_volume(&mut self, id: u64, volume: f32) {
|
||||
self.set_tracked_volume(id, volume);
|
||||
}
|
||||
fn stop(&mut self, id: u64) {
|
||||
self.stop_tracked(id);
|
||||
}
|
||||
fn is_active(&self, id: u64) -> bool {
|
||||
self.tracked_active(id)
|
||||
}
|
||||
fn play_at(&mut self, event: &str, at: [f64; 3]) {
|
||||
self.play(event, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
|
||||
struct LoopSound {
|
||||
id: Option<u64>,
|
||||
fade: i32,
|
||||
direction: i32,
|
||||
stopped: bool,
|
||||
}
|
||||
impl LoopSound {
|
||||
fn tick(&mut self) -> f32 {
|
||||
if self.fade < 0 {
|
||||
self.stopped = true;
|
||||
}
|
||||
self.fade += self.direction;
|
||||
(self.fade as f32 / 40.0).clamp(0.0, 1.0)
|
||||
}
|
||||
fn fade_out(&mut self) {
|
||||
self.fade = self.fade.min(40);
|
||||
self.direction = -1;
|
||||
}
|
||||
fn fade_in(&mut self) {
|
||||
self.fade = self.fade.max(0);
|
||||
self.direction = 1;
|
||||
}
|
||||
}
|
||||
/// What the handlers read about the player and level each tick.
|
||||
pub struct AmbientContext<'a> {
|
||||
/// `AMBIENT_SOUNDS` at the player's position.
|
||||
pub sounds: &'a AmbientSounds,
|
||||
/// Feet and eye position.
|
||||
/// The player's position (feet) and eye height.
|
||||
pub position: [f64; 3],
|
||||
pub eye_y: f64,
|
||||
/// `isEyeInFluid(WATER)` this tick (`Entity.updateIsUnderwater`).
|
||||
pub underwater: bool,
|
||||
/// Raw (sky, block) light at a block.
|
||||
pub spectator: bool,
|
||||
/// `Level.getBrightness` (sky, block) at a block.
|
||||
pub light: &'a dyn Fn([i32; 3]) -> (u8, u8),
|
||||
/// The bubble column in the player's box (its `drag` property), from
|
||||
/// `bubble_column_in`.
|
||||
pub bubble_column: Option<bool>,
|
||||
}
|
||||
/// `BubbleColumnAmbientSoundHandler`'s lookup: the first bubble column
|
||||
/// (x fastest, then y, then z) in the player's bounding box inflated by
|
||||
/// (0, -0.4F, 0) and deflated by 1.0E-6, as `drag_down`. `state` gives the
|
||||
/// bubble column at a block (None for anything else).
|
||||
pub fn bubble_column_in(bb: [f64; 6], state: impl Fn([i32; 3]) -> Option<bool>) -> Option<bool> {
|
||||
// AABB keeps min <= max on every construction.
|
||||
let make = |a: [f64; 6]| {
|
||||
[
|
||||
a[0].min(a[3]),
|
||||
a[1].min(a[4]),
|
||||
a[2].min(a[5]),
|
||||
a[0].max(a[3]),
|
||||
a[1].max(a[4]),
|
||||
a[2].max(a[5]),
|
||||
]
|
||||
};
|
||||
let inflate = |b: [f64; 6], x: f64, y: f64, z: f64| {
|
||||
make([b[0] - x, b[1] - y, b[2] - z, b[3] + x, b[4] + y, b[5] + z])
|
||||
};
|
||||
let b = inflate(make(bb), 0.0, f64::from(-0.4f32), 0.0);
|
||||
let b = inflate(b, -1.0E-6, -1.0E-6, -1.0E-6);
|
||||
let (x0, y0, z0) = (
|
||||
b[0].floor() as i32,
|
||||
b[1].floor() as i32,
|
||||
b[2].floor() as i32,
|
||||
);
|
||||
let (x1, y1, z1) = (
|
||||
b[3].floor() as i32,
|
||||
b[4].floor() as i32,
|
||||
b[5].floor() as i32,
|
||||
);
|
||||
for z in z0..=z1 {
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
if let Some(drag) = state([x, y, z]) {
|
||||
return Some(drag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// The local player's ambient sound handlers.
|
||||
pub struct Ambience {
|
||||
/// The client level's random.
|
||||
random: LegacyRandom,
|
||||
loops: Vec<(String, LoopSound)>,
|
||||
pub random: LegacyRandom,
|
||||
/// `BiomeAmbientSoundsHandler.loopSounds` (insertion order).
|
||||
loops: Vec<(String, InstanceId)>,
|
||||
previous_loop: Option<String>,
|
||||
pub moodiness: f32,
|
||||
/// `UnderwaterAmbientSoundHandler.tickDelay`.
|
||||
underwater_delay: i32,
|
||||
/// The underwater loop instance and its fade.
|
||||
underwater_loop: Option<(u64, i32)>,
|
||||
/// UnderLiquidSubSound instances (stop when the player leaves the water).
|
||||
sub_sounds: Vec<u64>,
|
||||
/// `LocalPlayer.wasUnderwater`.
|
||||
was_underwater: bool,
|
||||
/// `BubbleColumnAmbientSoundHandler.wasInBubbleColumn` / `firstTick`.
|
||||
was_in_bubble_column: bool,
|
||||
first_tick: bool,
|
||||
}
|
||||
impl Default for Ambience {
|
||||
fn default() -> Self {
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as i64);
|
||||
Self::new(seed)
|
||||
Self::new(crate::sound_engine::unique_seed())
|
||||
}
|
||||
}
|
||||
impl Ambience {
|
||||
@@ -171,53 +169,95 @@ impl Ambience {
|
||||
previous_loop: None,
|
||||
moodiness: 0.0,
|
||||
underwater_delay: 0,
|
||||
underwater_loop: None,
|
||||
sub_sounds: Vec::new(),
|
||||
was_underwater: false,
|
||||
was_in_bubble_column: false,
|
||||
first_tick: true,
|
||||
}
|
||||
}
|
||||
/// Stop everything (leaving the world).
|
||||
pub fn clear(&mut self, out: &mut impl AmbientOutput) {
|
||||
for (_, l) in self.loops.drain(..) {
|
||||
if let Some(id) = l.id {
|
||||
out.stop(id);
|
||||
}
|
||||
}
|
||||
if let Some((id, _)) = self.underwater_loop.take() {
|
||||
out.stop(id);
|
||||
}
|
||||
for id in self.sub_sounds.drain(..) {
|
||||
out.stop(id);
|
||||
/// `LocalPlayer.isUnderWater`.
|
||||
pub fn was_underwater(&self) -> bool {
|
||||
self.was_underwater
|
||||
}
|
||||
/// `BiomeAmbientSoundsHandler.loopSounds`.
|
||||
pub fn loops(&self) -> &[(String, InstanceId)] {
|
||||
&self.loops
|
||||
}
|
||||
/// `UnderwaterAmbientSoundHandler.tickDelay`.
|
||||
pub fn underwater_delay(&self) -> i32 {
|
||||
self.underwater_delay
|
||||
}
|
||||
/// A new player (leaving or changing worlds): fresh handlers.
|
||||
pub fn clear(&mut self, sounds: &mut impl Sounds) {
|
||||
for (_, id) in self.loops.drain(..) {
|
||||
sounds.release(id);
|
||||
}
|
||||
self.previous_loop = None;
|
||||
self.moodiness = 0.0;
|
||||
self.underwater_delay = 0;
|
||||
self.was_underwater = false;
|
||||
self.was_in_bubble_column = false;
|
||||
self.first_tick = true;
|
||||
}
|
||||
/// `LocalPlayer.updateIsUnderwater` enter/exit sounds and loop, then
|
||||
/// `UnderwaterAmbientSoundHandler`, `BubbleColumn` (absent) and
|
||||
/// `BiomeAmbientSoundsHandler` ticks, then the tickable instances.
|
||||
pub fn tick(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
let feet = ctx.position;
|
||||
if !self.was_underwater && ctx.underwater {
|
||||
out.play_at("minecraft:ambient.underwater.enter", feet);
|
||||
if let Some((id, _)) = self.underwater_loop.take() {
|
||||
out.stop(id);
|
||||
}
|
||||
self.underwater_loop = out
|
||||
.play_tracked("minecraft:ambient.underwater.loop", 0.0, true)
|
||||
.map(|id| (id, 0));
|
||||
}
|
||||
if self.was_underwater && !ctx.underwater {
|
||||
out.play_at("minecraft:ambient.underwater.exit", feet);
|
||||
}
|
||||
/// `ClientLevel.playLocalSound(x, y, z, sound, source, 1, 1, false)`.
|
||||
fn play_local(&mut self, event: &str, source: usize, at: [f64; 3], sounds: &mut impl Sounds) {
|
||||
let seed = self.random.next_long();
|
||||
let instance = SoundInstance::at(event, source, 1.0, 1.0, LegacyRandom::new(seed), at);
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
/// `LocalPlayer.updateIsUnderwater`, then the `ambientSoundHandlers`
|
||||
/// (underwater, bubble column, biome) as `LocalPlayer.tick` runs them.
|
||||
pub fn tick(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
|
||||
let old = self.was_underwater;
|
||||
self.was_underwater = ctx.underwater;
|
||||
self.tick_underwater(ctx, out);
|
||||
self.tick_biome(ctx, out);
|
||||
self.tick_instances(ctx, out);
|
||||
if !ctx.spectator {
|
||||
if !old && ctx.underwater {
|
||||
self.play_local(
|
||||
"minecraft:ambient.underwater.enter",
|
||||
AMBIENT,
|
||||
ctx.position,
|
||||
sounds,
|
||||
);
|
||||
let instance = SoundInstance::under_liquid_loop(
|
||||
"minecraft:ambient.underwater.loop",
|
||||
SoundEngine::unseeded(),
|
||||
);
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
if old && !ctx.underwater {
|
||||
self.play_local(
|
||||
"minecraft:ambient.underwater.exit",
|
||||
AMBIENT,
|
||||
ctx.position,
|
||||
sounds,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.tick_underwater(sounds);
|
||||
self.tick_bubble_column(ctx, sounds);
|
||||
self.tick_biome(ctx, sounds);
|
||||
}
|
||||
fn tick_underwater(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
/// `BubbleColumnAmbientSoundHandler.tick`: entering a column plays its
|
||||
/// inside sound (`LocalPlayer.playSound`, on PLAYERS).
|
||||
fn tick_bubble_column(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
|
||||
if let Some(drag_down) = ctx.bubble_column {
|
||||
if !self.was_in_bubble_column && !self.first_tick && !ctx.spectator {
|
||||
let event = if drag_down {
|
||||
"minecraft:block.bubble_column.whirlpool_inside"
|
||||
} else {
|
||||
"minecraft:block.bubble_column.upwards_inside"
|
||||
};
|
||||
self.play_local(event, PLAYERS, ctx.position, sounds);
|
||||
}
|
||||
self.was_in_bubble_column = true;
|
||||
} else {
|
||||
self.was_in_bubble_column = false;
|
||||
}
|
||||
self.first_tick = false;
|
||||
}
|
||||
/// `UnderwaterAmbientSoundHandler.tick`.
|
||||
fn tick_underwater(&mut self, sounds: &mut impl Sounds) {
|
||||
self.underwater_delay -= 1;
|
||||
if self.underwater_delay <= 0 && ctx.underwater {
|
||||
if self.underwater_delay <= 0 && self.was_underwater {
|
||||
let rand = self.random.next_float();
|
||||
let sound = if rand < 1.0E-4 {
|
||||
Some("minecraft:ambient.underwater.loop.additions.ultra_rare")
|
||||
@@ -230,147 +270,121 @@ impl Ambience {
|
||||
};
|
||||
if let Some(sound) = sound {
|
||||
self.underwater_delay = 0;
|
||||
if let Some(id) = out.play_tracked(sound, 1.0, false) {
|
||||
self.sub_sounds.push(id);
|
||||
}
|
||||
let instance = SoundInstance::under_liquid_sub(sound, SoundEngine::unseeded());
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn tick_biome(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
self.loops.retain(|(_, l)| !l.stopped);
|
||||
let sounds = ctx.sounds;
|
||||
if sounds.r#loop != self.previous_loop {
|
||||
self.previous_loop = sounds.r#loop.clone();
|
||||
for (_, l) in &mut self.loops {
|
||||
l.fade_out();
|
||||
/// `BiomeAmbientSoundsHandler.tick`.
|
||||
fn tick_biome(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
|
||||
let mut kept = Vec::new();
|
||||
for (event, id) in std::mem::take(&mut self.loops) {
|
||||
if sounds.engine().instance(id).is_some_and(|i| i.stopped) {
|
||||
sounds.release(id);
|
||||
} else {
|
||||
kept.push((event, id));
|
||||
}
|
||||
if let Some(event) = &sounds.r#loop {
|
||||
if !self.loops.iter().any(|(e, _)| e == event) {
|
||||
let id = out.play_tracked(event, 0.0, true);
|
||||
self.loops.push((
|
||||
event.clone(),
|
||||
LoopSound {
|
||||
id,
|
||||
fade: 0,
|
||||
direction: 0,
|
||||
stopped: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
self.loops = kept;
|
||||
let current = ctx
|
||||
.sounds
|
||||
.r#loop
|
||||
.as_deref()
|
||||
.map(crate::sound_events::normalize);
|
||||
if current != self.previous_loop {
|
||||
self.previous_loop = current.clone();
|
||||
for (_, id) in &self.loops {
|
||||
if let Some(i) = sounds.engine().instance_mut(*id) {
|
||||
i.fade_out();
|
||||
}
|
||||
if let Some((_, l)) = self.loops.iter_mut().find(|(e, _)| e == event) {
|
||||
l.fade_in();
|
||||
}
|
||||
if let Some(event) = current {
|
||||
let id = match self.loops.iter().find(|(e, _)| *e == event) {
|
||||
Some((_, id)) => *id,
|
||||
None => {
|
||||
let instance = SoundInstance::biome_loop(&event, SoundEngine::unseeded());
|
||||
let id = sounds.engine().add(instance, true);
|
||||
sounds.play_id(id);
|
||||
self.loops.push((event, id));
|
||||
id
|
||||
}
|
||||
};
|
||||
if let Some(i) = sounds.engine().instance_mut(id) {
|
||||
i.fade_in();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (sound, chance) in &sounds.additions {
|
||||
if self.random.next_double() < *chance
|
||||
&& let Some(id) = out.play_tracked(sound, 1.0, false)
|
||||
{
|
||||
// forAmbientAddition: a one-shot nobody ticks.
|
||||
let _ = id;
|
||||
for (sound, chance) in &ctx.sounds.additions {
|
||||
if self.random.next_double() < *chance {
|
||||
let event = crate::sound_events::normalize(sound);
|
||||
let instance = SoundInstance::for_ambient_addition(&event, SoundEngine::unseeded());
|
||||
sounds.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
let Some(mood) = &sounds.mood else { return };
|
||||
let Some(mood) = &ctx.sounds.mood else { return };
|
||||
let extent = mood.block_search_extent;
|
||||
let span = extent * 2 + 1;
|
||||
let mut offset = || self.random.next_int_bound(span).unwrap_or(0) - extent;
|
||||
let (dx, dy, dz) = (offset(), offset(), offset());
|
||||
let sample = [
|
||||
(ctx.position[0] + f64::from(dx)).floor() as i32,
|
||||
(ctx.eye_y + f64::from(dy)).floor() as i32,
|
||||
(ctx.position[2] + f64::from(dz)).floor() as i32,
|
||||
];
|
||||
let x = ctx.position[0] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
|
||||
- f64::from(extent);
|
||||
let y = ctx.eye_y + f64::from(self.random.next_int_bound(span).unwrap_or(0))
|
||||
- f64::from(extent);
|
||||
let z = ctx.position[2] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
|
||||
- f64::from(extent);
|
||||
let sample = [x.floor() as i32, y.floor() as i32, z.floor() as i32];
|
||||
let (sky, block) = (ctx.light)(sample);
|
||||
if sky > 0 {
|
||||
self.moodiness -= f32::from(sky) / 15.0 * 0.001;
|
||||
} else {
|
||||
self.moodiness -= (f32::from(block) - 1.0) / mood.tick_delay as f32;
|
||||
self.moodiness -= (i32::from(block) - 1) as f32 / mood.tick_delay as f32;
|
||||
}
|
||||
if self.moodiness >= 1.0 {
|
||||
let d = [
|
||||
f64::from(sample[0]) + 0.5 - ctx.position[0],
|
||||
f64::from(sample[1]) + 0.5 - ctx.eye_y,
|
||||
f64::from(sample[2]) + 0.5 - ctx.position[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
let sample_x = f64::from(sample[0]) + 0.5;
|
||||
let sample_y = f64::from(sample[1]) + 0.5;
|
||||
let sample_z = f64::from(sample[2]) + 0.5;
|
||||
let dx = sample_x - ctx.position[0];
|
||||
let dy = sample_y - ctx.eye_y;
|
||||
let dz = sample_z - ctx.position[2];
|
||||
let distance = (dx * dx + dy * dy + dz * dz).sqrt();
|
||||
let source = distance + mood.offset;
|
||||
out.play_at(
|
||||
&mood.sound,
|
||||
[
|
||||
ctx.position[0] + d[0] / distance * source,
|
||||
ctx.eye_y + d[1] / distance * source,
|
||||
ctx.position[2] + d[2] / distance * source,
|
||||
],
|
||||
);
|
||||
let at = [
|
||||
ctx.position[0] + dx / distance * source,
|
||||
ctx.eye_y + dy / distance * source,
|
||||
ctx.position[2] + dz / distance * source,
|
||||
];
|
||||
// The mood sound draws from the level random itself.
|
||||
let event = crate::sound_events::normalize(&mood.sound);
|
||||
let instance = SoundInstance::for_ambient_mood(&event, self.random.clone(), at);
|
||||
let id = sounds.engine().add(instance, true);
|
||||
sounds.play_id(id);
|
||||
if let Some(i) = sounds.engine().instance(id) {
|
||||
self.random = i.random.clone();
|
||||
}
|
||||
sounds.release(id);
|
||||
self.moodiness = 0.0;
|
||||
} else {
|
||||
self.moodiness = self.moodiness.max(0.0);
|
||||
}
|
||||
}
|
||||
/// The tickable instances' own ticks (SoundEngine.tickInGameSound).
|
||||
fn tick_instances(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
|
||||
for (_, l) in &mut self.loops {
|
||||
let volume = l.tick();
|
||||
if let Some(id) = l.id {
|
||||
if l.stopped {
|
||||
out.stop(id);
|
||||
} else {
|
||||
out.set_volume(id, volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
// UnderLiquidAmbientSoundInstance: +1 per tick under water, -2 out of it.
|
||||
if let Some((id, fade)) = self.underwater_loop.as_mut() {
|
||||
if *fade >= 0 {
|
||||
*fade += if ctx.underwater { 1 } else { -2 };
|
||||
*fade = (*fade).min(40);
|
||||
out.set_volume(*id, (*fade as f32 / 40.0).clamp(0.0, 1.0));
|
||||
} else {
|
||||
out.stop(*id);
|
||||
self.underwater_loop = None;
|
||||
}
|
||||
}
|
||||
// UnderLiquidSubSound: stops when the player leaves the water.
|
||||
self.sub_sounds.retain(|&id| {
|
||||
let keep = ctx.underwater && out.is_active(id);
|
||||
if !keep {
|
||||
out.stop(id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sound_engine::{Bound, TickContext, test_backend::Null};
|
||||
use crate::sound_events::SoundRegistry;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
next: u64,
|
||||
live: HashMap<u64, (String, f32)>,
|
||||
at: Vec<String>,
|
||||
}
|
||||
impl AmbientOutput for Fake {
|
||||
fn play_tracked(&mut self, event: &str, volume: f32, _looping: bool) -> Option<u64> {
|
||||
self.next += 1;
|
||||
self.live.insert(self.next, (event.to_owned(), volume));
|
||||
Some(self.next)
|
||||
}
|
||||
fn set_volume(&mut self, id: u64, volume: f32) {
|
||||
if let Some(v) = self.live.get_mut(&id) {
|
||||
v.1 = volume;
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, id: u64) {
|
||||
self.live.remove(&id);
|
||||
}
|
||||
fn is_active(&self, id: u64) -> bool {
|
||||
self.live.contains_key(&id)
|
||||
}
|
||||
fn play_at(&mut self, event: &str, _at: [f64; 3]) {
|
||||
self.at.push(event.to_owned());
|
||||
}
|
||||
fn engine() -> SoundEngine {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"crimson": {"sounds": ["c"]}, "warped": {"sounds": ["w"]},
|
||||
"ambient.cave": {"sounds": ["a", "b"]},
|
||||
"ambient.underwater.loop": {"sounds": ["u"]},
|
||||
"ambient.underwater.enter": {"sounds": ["e"]},
|
||||
"ambient.underwater.exit": {"sounds": ["x"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
SoundEngine::new(registry)
|
||||
}
|
||||
fn ctx<'a>(
|
||||
sounds: &'a AmbientSounds,
|
||||
@@ -382,49 +396,59 @@ mod tests {
|
||||
position: [0.5, 10.0, 0.5],
|
||||
eye_y: 11.62,
|
||||
underwater,
|
||||
spectator: false,
|
||||
light,
|
||||
bubble_column: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_cross_fade_over_forty_ticks() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
let mut a = Ambience::new(1);
|
||||
let mut out = Fake::default();
|
||||
let dark = |_: [i32; 3]| (15u8, 0u8);
|
||||
let lit = |_: [i32; 3]| (15u8, 0u8);
|
||||
let crimson = AmbientSounds {
|
||||
r#loop: Some("crimson".into()),
|
||||
r#loop: Some("minecraft:crimson".into()),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..40 {
|
||||
a.tick(&ctx(&crimson, false, &dark), &mut out);
|
||||
}
|
||||
assert_eq!(out.live.values().map(|v| v.1).collect::<Vec<_>>(), [1.0]);
|
||||
let warped = AmbientSounds {
|
||||
r#loop: Some("warped".into()),
|
||||
r#loop: Some("minecraft:warped".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let tick =
|
||||
|a: &mut Ambience, s: &AmbientSounds, engine: &mut SoundEngine, backend: &mut Null| {
|
||||
a.tick(
|
||||
&ctx(s, false, &lit),
|
||||
&mut Bound {
|
||||
engine: &mut *engine,
|
||||
backend: &mut *backend,
|
||||
},
|
||||
);
|
||||
engine.tick(false, &TickContext::default(), backend);
|
||||
};
|
||||
for _ in 0..40 {
|
||||
tick(&mut a, &crimson, &mut engine, &mut backend);
|
||||
}
|
||||
for _ in 0..20 {
|
||||
a.tick(&ctx(&warped, false, &dark), &mut out);
|
||||
tick(&mut a, &warped, &mut engine, &mut backend);
|
||||
}
|
||||
let mut volumes: Vec<_> = out.live.values().map(|v| (v.0.clone(), v.1)).collect();
|
||||
volumes.sort_by(|x, y| x.0.cmp(&y.0));
|
||||
assert_eq!(
|
||||
volumes,
|
||||
[("crimson".to_owned(), 0.5), ("warped".to_owned(), 0.5)]
|
||||
);
|
||||
let volumes: Vec<f32> = a
|
||||
.loops
|
||||
.iter()
|
||||
.map(|(_, id)| engine.instance(*id).unwrap().volume)
|
||||
.collect();
|
||||
assert_eq!(volumes, [0.5, 0.5]);
|
||||
for _ in 0..30 {
|
||||
a.tick(&ctx(&warped, false, &dark), &mut out);
|
||||
tick(&mut a, &warped, &mut engine, &mut backend);
|
||||
}
|
||||
assert_eq!(
|
||||
out.live.values().map(|v| v.0.as_str()).collect::<Vec<_>>(),
|
||||
["warped"]
|
||||
);
|
||||
assert_eq!(a.loops.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_builds_in_the_dark_and_plays_a_cave_sound() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
engine.listening = true;
|
||||
let mut a = Ambience::new(2);
|
||||
let mut out = Fake::default();
|
||||
let cave = |_: [i32; 3]| (0u8, 0u8);
|
||||
let sounds = AmbientSounds {
|
||||
mood: Some(Mood {
|
||||
@@ -435,31 +459,19 @@ mod tests {
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
// Block light 0: +1/6000 per tick (float), so the sound plays around tick 6000.
|
||||
let mut ticks = 0;
|
||||
while out.at.is_empty() && ticks < 7000 {
|
||||
a.tick(&ctx(&sounds, false, &cave), &mut out);
|
||||
while engine.notifications.is_empty() && ticks < 7000 {
|
||||
a.tick(
|
||||
&ctx(&sounds, false, &cave),
|
||||
&mut Bound {
|
||||
engine: &mut engine,
|
||||
backend: &mut backend,
|
||||
},
|
||||
);
|
||||
ticks += 1;
|
||||
}
|
||||
assert!((5990..=6010).contains(&ticks), "{ticks}");
|
||||
assert_eq!(out.at, ["minecraft:ambient.cave"]);
|
||||
assert_eq!(engine.notifications[0].identifier, "minecraft:ambient.cave");
|
||||
assert_eq!(a.moodiness, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_water_plays_enter_and_fades_the_loop_in() {
|
||||
let mut a = Ambience::new(3);
|
||||
let mut out = Fake::default();
|
||||
let lit = |_: [i32; 3]| (15u8, 0u8);
|
||||
let none = AmbientSounds::default();
|
||||
a.tick(&ctx(&none, true, &lit), &mut out);
|
||||
assert_eq!(out.at, ["minecraft:ambient.underwater.enter"]);
|
||||
let id = a.underwater_loop.unwrap().0;
|
||||
assert_eq!(out.live[&id].1, 1.0 / 40.0);
|
||||
a.tick(&ctx(&none, false, &lit), &mut out);
|
||||
assert_eq!(out.at.last().unwrap(), "minecraft:ambient.underwater.exit");
|
||||
// Fade 1 -> -1 stops on the next tick.
|
||||
a.tick(&ctx(&none, false, &lit), &mut out);
|
||||
assert!(!out.live.contains_key(&id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Atlas sprite sources (`SpriteSourceList`): the `atlases/*.json`
|
||||
//! definitions listing which sprites an atlas holds. `directory`, `single`,
|
||||
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
|
||||
//! against the resource tree; a later source replaces an earlier sprite of
|
||||
//! the same id and a filter removes the matching ones.
|
||||
use crate::packs::pattern::Pattern;
|
||||
use horizon_assets::palette::apply_palette;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// One region of an `unstitch` source, in units of the divisors.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Region {
|
||||
pub sprite: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum SpriteSource {
|
||||
Directory {
|
||||
source: String,
|
||||
prefix: String,
|
||||
},
|
||||
Single {
|
||||
resource: String,
|
||||
sprite: Option<String>,
|
||||
},
|
||||
Filter {
|
||||
namespace: Option<Pattern>,
|
||||
path: Option<Pattern>,
|
||||
},
|
||||
Unstitch {
|
||||
resource: String,
|
||||
regions: Vec<Region>,
|
||||
divisor_x: f64,
|
||||
divisor_y: f64,
|
||||
},
|
||||
PalettedPermutations {
|
||||
textures: Vec<String>,
|
||||
palette_key: String,
|
||||
permutations: BTreeMap<String, String>,
|
||||
separator: String,
|
||||
},
|
||||
}
|
||||
/// Where a listed sprite's pixels come from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SpriteOrigin {
|
||||
/// A whole texture (`textures/<path>.png`), by texture identifier.
|
||||
File(String),
|
||||
/// `Unstitcher.RegionInstance`: part of a texture.
|
||||
Region {
|
||||
texture: String,
|
||||
region: Region,
|
||||
divisor_x: f64,
|
||||
divisor_y: f64,
|
||||
},
|
||||
/// `PalettedSpriteSupplier`: a texture with its colours remapped.
|
||||
Paletted {
|
||||
texture: String,
|
||||
palette_key: String,
|
||||
palette: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `Identifier.parse` default namespace.
|
||||
pub fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
fn string(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key)?.as_str().map(str::to_owned)
|
||||
}
|
||||
fn number(v: &Value, key: &str, default: f64) -> Option<f64> {
|
||||
match v.get(key) {
|
||||
None => Some(default),
|
||||
Some(n) => n.as_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpriteSources.FILE_CODEC`: the `sources` list of an atlas definition.
|
||||
/// A malformed source fails the whole file, as the codec does.
|
||||
pub fn decode(text: &str) -> Option<Vec<SpriteSource>> {
|
||||
let json: Value = serde_json::from_str(text).ok()?;
|
||||
json.get("sources")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(decode_source)
|
||||
.collect()
|
||||
}
|
||||
fn decode_source(s: &Value) -> Option<SpriteSource> {
|
||||
Some(match qualified(s.get("type")?.as_str()?).as_str() {
|
||||
"minecraft:directory" => SpriteSource::Directory {
|
||||
source: string(s, "source")?,
|
||||
prefix: string(s, "prefix")?,
|
||||
},
|
||||
"minecraft:single" => SpriteSource::Single {
|
||||
resource: qualified(&string(s, "resource")?),
|
||||
sprite: match s.get("sprite") {
|
||||
None => None,
|
||||
Some(v) => Some(qualified(v.as_str()?)),
|
||||
},
|
||||
},
|
||||
"minecraft:filter" => {
|
||||
let p = s.get("pattern")?;
|
||||
let pattern = |key: &str| -> Option<Option<Pattern>> {
|
||||
match p.get(key) {
|
||||
None => Some(None),
|
||||
Some(v) => Pattern::new(v.as_str()?).map(Some),
|
||||
}
|
||||
};
|
||||
SpriteSource::Filter {
|
||||
namespace: pattern("namespace")?,
|
||||
path: pattern("path")?,
|
||||
}
|
||||
}
|
||||
"minecraft:unstitch" => {
|
||||
let regions = s
|
||||
.get("regions")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Some(Region {
|
||||
sprite: qualified(&string(r, "sprite")?),
|
||||
x: r.get("x")?.as_f64()?,
|
||||
y: r.get("y")?.as_f64()?,
|
||||
width: r.get("width")?.as_f64()?,
|
||||
height: r.get("height")?.as_f64()?,
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if regions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
SpriteSource::Unstitch {
|
||||
resource: qualified(&string(s, "resource")?),
|
||||
regions,
|
||||
divisor_x: number(s, "divisor_x", 1.0)?,
|
||||
divisor_y: number(s, "divisor_y", 1.0)?,
|
||||
}
|
||||
}
|
||||
"minecraft:paletted_permutations" => SpriteSource::PalettedPermutations {
|
||||
textures: s
|
||||
.get("textures")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|t| t.as_str().map(qualified))
|
||||
.collect::<Option<_>>()?,
|
||||
palette_key: qualified(&string(s, "palette_key")?),
|
||||
permutations: s
|
||||
.get("permutations")?
|
||||
.as_object()?
|
||||
.iter()
|
||||
.map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
|
||||
.collect::<Option<_>>()?,
|
||||
separator: match s.get("separator") {
|
||||
None => "_".to_owned(),
|
||||
Some(v) => v.as_str()?.to_owned(),
|
||||
},
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The texture files of a resource tree: `namespace:path` for every
|
||||
/// `assets/<namespace>/textures/<path>.png`.
|
||||
pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("textures");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().is_some_and(|x| x == "png")
|
||||
&& let Ok(rel) = p.strip_prefix(&base)
|
||||
{
|
||||
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
|
||||
out.push(format!("{namespace}:{rel}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
/// The file of a texture identifier.
|
||||
pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
/// `textures` are the available texture identifiers ([`list_textures`]).
|
||||
pub fn list(sources: &[SpriteSource], textures: &[String]) -> BTreeMap<String, SpriteOrigin> {
|
||||
let mut out = BTreeMap::new();
|
||||
let exists = |t: &str| textures.binary_search_by(|x| x.as_str().cmp(t)).is_ok();
|
||||
for source in sources {
|
||||
match source {
|
||||
SpriteSource::Directory { source, prefix } => {
|
||||
// FileToIdConverter("textures/" + source, ".png").
|
||||
let dir = format!("{source}/");
|
||||
for t in textures {
|
||||
let (namespace, path) = t.split_once(':').unwrap_or(("minecraft", t));
|
||||
if let Some(rest) = path.strip_prefix(&dir) {
|
||||
out.insert(
|
||||
format!("{namespace}:{prefix}{rest}"),
|
||||
SpriteOrigin::File(t.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpriteSource::Single { resource, sprite } => {
|
||||
if exists(resource) {
|
||||
out.insert(
|
||||
sprite.clone().unwrap_or_else(|| resource.clone()),
|
||||
SpriteOrigin::File(resource.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
SpriteSource::Filter { namespace, path } => {
|
||||
out.retain(|id: &String, _| {
|
||||
let (ns, p) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
!(namespace.as_ref().is_none_or(|m| m.is_match(ns))
|
||||
&& path.as_ref().is_none_or(|m| m.is_match(p)))
|
||||
});
|
||||
}
|
||||
SpriteSource::Unstitch {
|
||||
resource,
|
||||
regions,
|
||||
divisor_x,
|
||||
divisor_y,
|
||||
} => {
|
||||
if exists(resource) {
|
||||
for region in regions {
|
||||
out.insert(
|
||||
region.sprite.clone(),
|
||||
SpriteOrigin::Region {
|
||||
texture: resource.clone(),
|
||||
region: region.clone(),
|
||||
divisor_x: *divisor_x,
|
||||
divisor_y: *divisor_y,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpriteSource::PalettedPermutations {
|
||||
textures: bases,
|
||||
palette_key,
|
||||
permutations,
|
||||
separator,
|
||||
} => {
|
||||
for base in bases {
|
||||
if !exists(base) {
|
||||
continue;
|
||||
}
|
||||
for (suffix, palette) in permutations {
|
||||
out.insert(
|
||||
format!("{base}{separator}{suffix}"),
|
||||
SpriteOrigin::Paletted {
|
||||
texture: base.clone(),
|
||||
palette_key: palette_key.clone(),
|
||||
palette: palette.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An RGBA image.
|
||||
pub type Image = (u32, u32, Vec<u8>);
|
||||
|
||||
fn argb(rgba: &[u8]) -> Vec<u32> {
|
||||
rgba.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from_be_bytes([p[3], p[0], p[1], p[2]]))
|
||||
.collect()
|
||||
}
|
||||
/// The pixels of a generated sprite (`Region` or `Paletted`); `None` for a
|
||||
/// plain file, which loads as itself.
|
||||
pub fn generate(
|
||||
origin: &SpriteOrigin,
|
||||
load: &dyn Fn(&str, &str) -> Option<Image>,
|
||||
) -> Option<Image> {
|
||||
match origin {
|
||||
SpriteOrigin::File(_) => None,
|
||||
SpriteOrigin::Region {
|
||||
texture,
|
||||
region,
|
||||
divisor_x,
|
||||
divisor_y,
|
||||
} => {
|
||||
let (w, h, pixels) = load(texture, "textures")?;
|
||||
let x_scale = f64::from(w) / divisor_x;
|
||||
let y_scale = f64::from(h) / divisor_y;
|
||||
let x = (region.x * x_scale).floor() as i64;
|
||||
let y = (region.y * y_scale).floor() as i64;
|
||||
let width = (region.width * x_scale).floor() as i64;
|
||||
let height = (region.height * y_scale).floor() as i64;
|
||||
if width <= 0 || height <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0u8; (width * height * 4) as usize];
|
||||
for row in 0..height {
|
||||
for col in 0..width {
|
||||
let (sx, sy) = (x + col, y + row);
|
||||
if sx < 0 || sy < 0 || sx >= i64::from(w) || sy >= i64::from(h) {
|
||||
continue;
|
||||
}
|
||||
let s = ((sy * i64::from(w) + sx) * 4) as usize;
|
||||
let d = ((row * width + col) * 4) as usize;
|
||||
out[d..d + 4].copy_from_slice(&pixels[s..s + 4]);
|
||||
}
|
||||
}
|
||||
Some((width as u32, height as u32, out))
|
||||
}
|
||||
SpriteOrigin::Paletted {
|
||||
texture,
|
||||
palette_key,
|
||||
palette,
|
||||
} => {
|
||||
let (w, h, pixels) = load(texture, "textures")?;
|
||||
let (_, _, key) = load(palette_key, "textures/palettes")?;
|
||||
let (_, _, target) = load(palette, "textures/palettes")?;
|
||||
let mut mapped = argb(&pixels);
|
||||
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
|
||||
let out = mapped
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let [a, r, g, b] = p.to_be_bytes();
|
||||
[r, g, b, a]
|
||||
})
|
||||
.collect();
|
||||
Some((w, h, out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every atlas definition of a resource tree (`atlases/*.json` in every
|
||||
/// namespace), by atlas id.
|
||||
pub fn atlases(root: &Path) -> BTreeMap<String, Vec<SpriteSource>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return out;
|
||||
};
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let Ok(files) = fs::read_dir(ns.path().join("atlases")) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
let path = f.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
if let Some(sources) = fs::read_to_string(&path).ok().and_then(|t| decode(&t)) {
|
||||
out.insert(format!("{namespace}:{name}"), sources);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn textures() -> Vec<String> {
|
||||
let mut t: Vec<String> = [
|
||||
"minecraft:item/apple",
|
||||
"minecraft:item/stick",
|
||||
"minecraft:trims/items/boots_trim",
|
||||
"minecraft:block/stone",
|
||||
"minecraft:entity/sheet",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
t.sort();
|
||||
t
|
||||
}
|
||||
#[test]
|
||||
fn sources_run_in_order() {
|
||||
let sources = decode(
|
||||
r#"{"sources":[
|
||||
{"type":"directory","source":"item","prefix":"item/"},
|
||||
{"type":"minecraft:single","resource":"block/stone","sprite":"item/stone"},
|
||||
{"type":"filter","pattern":{"path":"stick"}},
|
||||
{"type":"unstitch","resource":"entity/sheet","divisor_x":2,"regions":[{"sprite":"a","x":1,"y":0,"width":1,"height":1}]},
|
||||
{"type":"paletted_permutations","textures":["trims/items/boots_trim"],"palette_key":"trims/color_palettes/trim_palette","permutations":{"quartz":"trims/color_palettes/quartz"}}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let listed = list(&sources, &textures());
|
||||
let ids: Vec<&str> = listed.keys().map(String::as_str).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
"minecraft:a",
|
||||
"minecraft:item/apple",
|
||||
"minecraft:item/stone",
|
||||
"minecraft:trims/items/boots_trim_quartz"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
listed["minecraft:item/stone"],
|
||||
SpriteOrigin::File("minecraft:block/stone".into())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_sources_fail_the_file() {
|
||||
assert!(decode(r#"{"sources":[{"type":"directory","source":"item"}]}"#).is_none());
|
||||
assert!(
|
||||
decode(r#"{"sources":[{"type":"unstitch","resource":"x","regions":[]}]}"#).is_none()
|
||||
);
|
||||
assert!(decode(r#"{"sources":[{"type":"horizon"}]}"#).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn unstitch_cuts_by_divisor() {
|
||||
let pixels: Vec<u8> = (0..16u8).flat_map(|i| [i, i, i, 255]).collect();
|
||||
let load = |_: &str, _: &str| Some((4u32, 4u32, pixels.clone()));
|
||||
let origin = SpriteOrigin::Region {
|
||||
texture: "t".into(),
|
||||
region: Region {
|
||||
sprite: "s".into(),
|
||||
x: 1.0,
|
||||
y: 1.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
divisor_x: 2.0,
|
||||
divisor_y: 2.0,
|
||||
};
|
||||
let (w, h, out) = generate(&origin, &load).unwrap();
|
||||
assert_eq!((w, h), (2, 2));
|
||||
assert_eq!(out[0], 10);
|
||||
assert_eq!(out[4], 11);
|
||||
assert_eq!(out[8], 14);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! The player's attack strength (`Player.attackStrengthTicker`,
|
||||
//! `itemSwapTicker`, `getAttackStrengthScale`, `getCurrentItemAttackStrengthDelay`):
|
||||
//! the charge the attack indicator shows and attacks scale their damage by.
|
||||
|
||||
/// `Attributes.ATTACK_SPEED` base value.
|
||||
pub const BASE_ATTACK_SPEED: f64 = 4.0;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AttackStrength {
|
||||
/// `LivingEntity.attackStrengthTicker`.
|
||||
pub ticker: i32,
|
||||
/// `LivingEntity.itemSwapTicker`.
|
||||
pub swap_ticker: i32,
|
||||
/// `Player.lastItemInMainHand`: the stack as last compared.
|
||||
last_item: Option<HeldStack>,
|
||||
}
|
||||
/// What `ItemStack.matches` / `isSameItem` compare of the main-hand stack:
|
||||
/// its item id and the rest of the stack (count and components) as a key.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HeldStack {
|
||||
pub item: String,
|
||||
pub rest: String,
|
||||
}
|
||||
/// `Player.getCurrentItemAttackStrengthDelay` for an ATTACK_SPEED value.
|
||||
pub fn delay(attack_speed: f64) -> f32 {
|
||||
(1.0 / attack_speed * 20.0) as f32
|
||||
}
|
||||
impl AttackStrength {
|
||||
/// The attack-strength part of `Player.tick`: both tickers advance, and a
|
||||
/// main-hand change to a different item resets them
|
||||
/// (`!ItemStack.matches` and `!ItemStack.isSameItem`).
|
||||
pub fn tick(&mut self, main_hand: Option<HeldStack>) {
|
||||
self.ticker += 1;
|
||||
self.swap_ticker += 1;
|
||||
if self.last_item != main_hand {
|
||||
let same_item = match (&self.last_item, &main_hand) {
|
||||
(Some(a), Some(b)) => a.item == b.item,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same_item {
|
||||
self.reset();
|
||||
}
|
||||
self.last_item = main_hand;
|
||||
}
|
||||
}
|
||||
/// `resetAttackStrengthTicker`: swinging at nothing, attacking or
|
||||
/// stopping a block break.
|
||||
pub fn reset(&mut self) {
|
||||
self.ticker = 0;
|
||||
self.swap_ticker = 0;
|
||||
}
|
||||
/// `resetOnlyAttackStrengthTicker` (`Player.onAttack`).
|
||||
pub fn reset_attack(&mut self) {
|
||||
self.ticker = 0;
|
||||
}
|
||||
/// `getAttackStrengthScale(a)` with the ATTACK_SPEED attribute value.
|
||||
pub fn scale(&self, a: f32, attack_speed: f64) -> f32 {
|
||||
((self.ticker as f32 + a) / delay(attack_speed)).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `getItemSwapScale(a)`.
|
||||
pub fn swap_scale(&self, a: f32, attack_speed: f64) -> f32 {
|
||||
((self.swap_ticker as f32 + a) / delay(attack_speed)).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn held(item: &str, rest: &str) -> Option<HeldStack> {
|
||||
Some(HeldStack {
|
||||
item: item.into(),
|
||||
rest: rest.into(),
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn charges_over_the_attack_delay() {
|
||||
let mut s = AttackStrength::default();
|
||||
// A sword's 1.6 attacks per second: 12.5 ticks to full strength.
|
||||
assert_eq!(delay(1.6), 12.5);
|
||||
for _ in 0..6 {
|
||||
s.tick(None);
|
||||
}
|
||||
assert_eq!(s.scale(0.25, 1.6), 6.25 / 12.5);
|
||||
for _ in 0..10 {
|
||||
s.tick(None);
|
||||
}
|
||||
assert_eq!(s.scale(0.0, 1.6), 1.0);
|
||||
}
|
||||
#[test]
|
||||
fn switching_items_resets_but_restacking_does_not() {
|
||||
let mut s = AttackStrength::default();
|
||||
s.tick(held("minecraft:stone", "64"));
|
||||
assert_eq!(s.ticker, 0, "picking up an item resets");
|
||||
s.tick(held("minecraft:stone", "63"));
|
||||
assert_eq!(
|
||||
s.ticker, 1,
|
||||
"the same item with another count keeps charging"
|
||||
);
|
||||
s.tick(held("minecraft:diamond_sword", ""));
|
||||
assert_eq!((s.ticker, s.swap_ticker), (0, 0));
|
||||
s.tick(held("minecraft:diamond_sword", ""));
|
||||
s.reset_attack();
|
||||
assert_eq!((s.ticker, s.swap_ticker), (0, 1));
|
||||
}
|
||||
}
|
||||
+466
-436
@@ -1,9 +1,16 @@
|
||||
//! Sound playback: `sounds.json` events with weighted variants, Ogg/Vorbis
|
||||
//! decoding, and a small mixer with vanilla's linear distance attenuation
|
||||
//! (`SoundInstance.Attenuation.LINEAR`, default range 16 blocks) and stereo panning.
|
||||
//! Sound playback: vanilla's `SoundEngine` bookkeeping (`sound_engine`) over a
|
||||
//! small mixer standing in for OpenAL: one channel per source with its gain,
|
||||
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
|
||||
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
|
||||
//! (streams on a worker thread) and stereo panning.
|
||||
use crate::Result;
|
||||
use crate::sound_engine::{
|
||||
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
|
||||
Sounds, TickContext,
|
||||
};
|
||||
use crate::sound_events::{Sound, SoundRegistry};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use serde_json::Value;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
@@ -11,46 +18,32 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Variant {
|
||||
name: String,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
weight: u32,
|
||||
attenuation: f32,
|
||||
is_event: bool,
|
||||
/// An OpenAL source's state.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum State {
|
||||
Initial,
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
}
|
||||
struct Voice {
|
||||
samples: Arc<Vec<f32>>,
|
||||
/// An OpenAL source.
|
||||
struct Channel {
|
||||
samples: Option<(Arc<Vec<f32>>, u32)>,
|
||||
position: f64,
|
||||
step: f64,
|
||||
left: f32,
|
||||
right: f32,
|
||||
/// The sound event and `SoundSource` index (what `/stopsound` matches).
|
||||
event: Arc<str>,
|
||||
source: usize,
|
||||
state: State,
|
||||
gain: f32,
|
||||
pitch: f32,
|
||||
/// `AL_LINEAR_DISTANCE` max distance, or no attenuation.
|
||||
linear: Option<f32>,
|
||||
looping: bool,
|
||||
at: [f32; 3],
|
||||
relative: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
voices: Vec<Voice>,
|
||||
/// The streaming music track (`SoundSource.MUSIC`), kept apart from the
|
||||
/// voice cap and scaled by `music_gain` (MusicManager's fade).
|
||||
music: Option<Voice>,
|
||||
music_gain: f32,
|
||||
/// Tickable sound instances the game keeps a handle to (ambient loops).
|
||||
tracked: Vec<Tracked>,
|
||||
}
|
||||
struct Tracked {
|
||||
id: u64,
|
||||
voice: Voice,
|
||||
looping: bool,
|
||||
volume: f32,
|
||||
}
|
||||
/// The current music track: decoding on a worker until the voice exists.
|
||||
struct MusicTrack {
|
||||
event: String,
|
||||
/// Set by the decoder thread once the voice is in the mixer (or failed).
|
||||
ready: Arc<std::sync::atomic::AtomicBool>,
|
||||
channels: HashMap<ChannelId, Channel>,
|
||||
listener: [f64; 3],
|
||||
listener_yaw: f32,
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
@@ -58,30 +51,31 @@ struct MusicTrack {
|
||||
pub struct Heard {
|
||||
/// The `subtitle` translation key of the sound event.
|
||||
pub subtitle: String,
|
||||
/// Where it played (None = relative to the listener, e.g. UI sounds).
|
||||
/// The instance's position (relative sounds report their raw coordinates).
|
||||
pub at: Option<[f64; 3]>,
|
||||
/// `SoundEngine.getRange`: infinite for relative and non-attenuated sounds.
|
||||
pub range: f32,
|
||||
pub looping: bool,
|
||||
}
|
||||
/// The mixer side of the engine (`Backend`).
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
root: PathBuf,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
next: ChannelId,
|
||||
live: bool,
|
||||
}
|
||||
pub struct Audio {
|
||||
_stream: Option<cpal::Stream>,
|
||||
/// `subtitle` keys of `sounds.json` events.
|
||||
subtitles: HashMap<String, String>,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
pub record_heard: bool,
|
||||
heard: Vec<Heard>,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
rate: u32,
|
||||
root: PathBuf,
|
||||
events: HashMap<String, Vec<Variant>>,
|
||||
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
|
||||
seed: u64,
|
||||
pub listener: [f64; 3],
|
||||
pub listener_yaw: f32,
|
||||
pub master: f32,
|
||||
music: Option<MusicTrack>,
|
||||
next_tracked: u64,
|
||||
/// Seeds of positional plays (the client level random's `nextLong`).
|
||||
random: LegacyRandom,
|
||||
/// A `SoundSource` index the next sound plays on (`/playsound`).
|
||||
source_override: Option<usize>,
|
||||
/// Positional sounds played since the last drain, kept while the world
|
||||
@@ -130,75 +124,261 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
}
|
||||
Some((mono, rate))
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
let mut events = HashMap::new();
|
||||
let mut subtitles = HashMap::new();
|
||||
if let Ok(text) = fs::read(root.join("assets/minecraft/sounds.json"))
|
||||
&& let Ok(Value::Object(map)) = serde_json::from_slice::<Value>(&text)
|
||||
impl MixerBackend {
|
||||
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
for (name, def) in map {
|
||||
let variants = def["sounds"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|s| match s {
|
||||
Value::String(n) => Variant {
|
||||
name: n.clone(),
|
||||
volume: 1.0,
|
||||
pitch: 1.0,
|
||||
weight: 1,
|
||||
attenuation: 16.0,
|
||||
is_event: false,
|
||||
},
|
||||
_ => Variant {
|
||||
name: s["name"].as_str().unwrap_or("").to_owned(),
|
||||
volume: s["volume"].as_f64().unwrap_or(1.0) as f32,
|
||||
pitch: s["pitch"].as_f64().unwrap_or(1.0) as f32,
|
||||
weight: s["weight"].as_u64().unwrap_or(1) as u32,
|
||||
attenuation: s["attenuation_distance"].as_f64().unwrap_or(16.0) as f32,
|
||||
is_event: s["type"].as_str() == Some("event"),
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(key) = def["subtitle"].as_str() {
|
||||
subtitles.insert(format!("minecraft:{name}"), key.to_owned());
|
||||
}
|
||||
events.insert(format!("minecraft:{name}"), variants);
|
||||
}
|
||||
f(c);
|
||||
}
|
||||
let mixer = Arc::new(Mutex::new(Mixer {
|
||||
music_gain: 1.0,
|
||||
..Mixer::default()
|
||||
}));
|
||||
let (stream, rate) = match Self::open(mixer.clone()) {
|
||||
Ok(v) => (Some(v.0), v.1),
|
||||
Err(e) => {
|
||||
eprintln!("Horizon: audio unavailable: {e}");
|
||||
(None, 48_000)
|
||||
}
|
||||
}
|
||||
impl Backend for MixerBackend {
|
||||
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
|
||||
self.next += 1;
|
||||
let id = self.next;
|
||||
self.mixer.lock().ok()?.channels.insert(
|
||||
id,
|
||||
Channel {
|
||||
samples: None,
|
||||
position: 0.0,
|
||||
state: State::Initial,
|
||||
gain: 1.0,
|
||||
pitch: 1.0,
|
||||
linear: None,
|
||||
looping: false,
|
||||
at: [0.0; 3],
|
||||
relative: false,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
}
|
||||
fn destroy(&mut self, channel: ChannelId) {
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.channels.remove(&channel);
|
||||
}
|
||||
}
|
||||
fn set_pitch(&mut self, channel: ChannelId, pitch: f32) {
|
||||
self.with(channel, |c| c.pitch = pitch);
|
||||
}
|
||||
fn set_volume(&mut self, channel: ChannelId, volume: f32) {
|
||||
self.with(channel, |c| c.gain = volume);
|
||||
}
|
||||
fn set_attenuation(&mut self, channel: ChannelId, linear: Option<f32>) {
|
||||
self.with(channel, |c| c.linear = linear);
|
||||
}
|
||||
fn set_looping(&mut self, channel: ChannelId, looping: bool) {
|
||||
self.with(channel, |c| c.looping = looping);
|
||||
}
|
||||
fn set_position(&mut self, channel: ChannelId, p: [f64; 3]) {
|
||||
self.with(channel, |c| c.at = [p[0] as f32, p[1] as f32, p[2] as f32]);
|
||||
}
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
|
||||
/// worker), then attach and `alSourcePlay`. A sound that cannot load
|
||||
/// leaves its source initial, as a failed buffer future does in vanilla.
|
||||
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
|
||||
if !self.live {
|
||||
return;
|
||||
}
|
||||
let (namespace, path) = sound.path();
|
||||
let file = self.root.join("assets").join(namespace).join(&path);
|
||||
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
|
||||
if let Some(samples) = samples
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.samples = Some(samples);
|
||||
c.position = 0.0;
|
||||
c.state = State::Playing;
|
||||
}
|
||||
};
|
||||
if sound.stream {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
std::thread::spawn(move || {
|
||||
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
|
||||
if looping
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.looping = true;
|
||||
}
|
||||
attach(&mixer, samples);
|
||||
});
|
||||
} else {
|
||||
let samples = self
|
||||
.cache
|
||||
.entry(format!("{namespace}:{path}"))
|
||||
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
|
||||
.clone();
|
||||
attach(&self.mixer, samples);
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state != State::Initial {
|
||||
c.state = State::Stopped;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn pause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state == State::Playing {
|
||||
c.state = State::Paused;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn unpause(&mut self, channel: ChannelId) {
|
||||
self.with(channel, |c| {
|
||||
if c.state == State::Paused {
|
||||
c.state = State::Playing;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn stopped(&mut self, channel: ChannelId) -> bool {
|
||||
self.mixer
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.channels
|
||||
.get(&channel)
|
||||
.is_none_or(|c| c.state == State::Stopped)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
|
||||
/// distance 0 and rolloff 1: the distance clamps to the max distance).
|
||||
fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
|
||||
match linear {
|
||||
None => 1.0,
|
||||
Some(max) if max > 0.0 => (1.0 - distance.min(max) / max).clamp(0.0, 1.0),
|
||||
Some(_) => 0.0,
|
||||
}
|
||||
}
|
||||
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
data.fill(0.0);
|
||||
let listener = mixer.listener;
|
||||
let yaw = mixer.listener_yaw.to_radians();
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices: Vec<(ChannelId, f32, f32, f64)> = Vec::new();
|
||||
for (id, c) in &mixer.channels {
|
||||
let Some((_, source_rate)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
let d = if c.relative {
|
||||
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
|
||||
} else {
|
||||
[
|
||||
f64::from(c.at[0]) - listener[0],
|
||||
f64::from(c.at[1]) - listener[1],
|
||||
f64::from(c.at[2]) - listener[2],
|
||||
]
|
||||
};
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
// The listener's right vector for a Minecraft yaw.
|
||||
let side = if c.relative {
|
||||
(d[0] as f32) / distance.max(0.001)
|
||||
} else {
|
||||
(-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32 / distance.max(0.001)
|
||||
};
|
||||
let pan = side.clamp(-1.0, 1.0);
|
||||
let left = gain * (1.0 - pan.max(0.0) * 0.6);
|
||||
let right = gain * (1.0 + pan.min(0.0) * 0.6);
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, left, right, step));
|
||||
}
|
||||
for (id, left, right, step) in voices {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
let Some((samples, _)) = c.samples.clone() else {
|
||||
continue;
|
||||
};
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let mut i = c.position as usize;
|
||||
if i + 1 >= samples.len() {
|
||||
if c.looping && samples.len() > 1 {
|
||||
c.position = 0.0;
|
||||
i = 0;
|
||||
} else {
|
||||
c.state = State::Stopped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
} else {
|
||||
frame[0] += s * (left + right) / 2.0;
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
for s in data.iter_mut() {
|
||||
*s = s.clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
}
|
||||
/// No output device: the dedicated server.
|
||||
pub fn silent(root: &Path) -> Self {
|
||||
Self::with_output(root, false)
|
||||
}
|
||||
fn with_output(root: &Path, output: bool) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
let opened = if output {
|
||||
Self::open(mixer.clone())
|
||||
} else {
|
||||
Err("no output on a dedicated server".into())
|
||||
};
|
||||
let (stream, live) = match opened {
|
||||
Ok(v) => (Some(v), true),
|
||||
Err(e) => {
|
||||
if output {
|
||||
eprintln!("Horizon: audio unavailable: {e}");
|
||||
}
|
||||
(None, false)
|
||||
}
|
||||
};
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
engine.loaded = live;
|
||||
Self {
|
||||
_stream: stream,
|
||||
subtitles,
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
root: root.to_owned(),
|
||||
cache: HashMap::new(),
|
||||
next: 0,
|
||||
live,
|
||||
},
|
||||
record_heard: false,
|
||||
heard: Vec::new(),
|
||||
mixer,
|
||||
rate,
|
||||
root: root.to_owned(),
|
||||
events,
|
||||
cache: HashMap::new(),
|
||||
seed: 0x5DEE_CE66,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
master: 1.0,
|
||||
music: None,
|
||||
next_tracked: 0,
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
source_override: None,
|
||||
capture: None,
|
||||
}
|
||||
}
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<(cpal::Stream, u32)> {
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
|
||||
// Test runs (harnesses, agents) launch the game on a virtual screen;
|
||||
// keep them off the user's speakers.
|
||||
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
|
||||
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
@@ -209,269 +389,110 @@ impl Audio {
|
||||
let stream = device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _| {
|
||||
data.fill(0.0);
|
||||
let Ok(mut mixer) = mixer.lock() else { return };
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let (mut l, mut r) = (0.0f32, 0.0f32);
|
||||
for v in mixer.voices.iter_mut() {
|
||||
let i = v.position as usize;
|
||||
if i + 1 >= v.samples.len() {
|
||||
continue;
|
||||
}
|
||||
let t = (v.position - i as f64) as f32;
|
||||
let s = v.samples[i] * (1.0 - t) + v.samples[i + 1] * t;
|
||||
l += s * v.left;
|
||||
r += s * v.right;
|
||||
v.position += v.step;
|
||||
}
|
||||
for t in mixer.tracked.iter_mut() {
|
||||
let v = &mut t.voice;
|
||||
let mut i = v.position as usize;
|
||||
if t.looping && i + 1 >= v.samples.len() {
|
||||
v.position = 0.0;
|
||||
i = 0;
|
||||
}
|
||||
if i + 1 < v.samples.len() {
|
||||
let f = (v.position - i as f64) as f32;
|
||||
let s = (v.samples[i] * (1.0 - f) + v.samples[i + 1] * f) * t.volume;
|
||||
l += s * v.left;
|
||||
r += s * v.right;
|
||||
v.position += v.step;
|
||||
}
|
||||
}
|
||||
let gain = mixer.music_gain;
|
||||
if let Some(v) = mixer.music.as_mut() {
|
||||
let i = v.position as usize;
|
||||
if i + 1 < v.samples.len() {
|
||||
let t = (v.position - i as f64) as f32;
|
||||
let s = (v.samples[i] * (1.0 - t) + v.samples[i + 1] * t) * gain;
|
||||
l += s * v.left;
|
||||
r += s * v.right;
|
||||
v.position += v.step;
|
||||
}
|
||||
}
|
||||
if channels >= 2 {
|
||||
frame[0] = l.clamp(-1.0, 1.0);
|
||||
frame[1] = r.clamp(-1.0, 1.0);
|
||||
} else {
|
||||
frame[0] = ((l + r) / 2.0).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
mixer
|
||||
.voices
|
||||
.retain(|v| (v.position as usize) + 1 < v.samples.len());
|
||||
mixer.tracked.retain(|t| {
|
||||
t.looping || (t.voice.position as usize) + 1 < t.voice.samples.len()
|
||||
});
|
||||
if mixer
|
||||
.music
|
||||
.as_ref()
|
||||
.is_some_and(|v| (v.position as usize) + 1 >= v.samples.len())
|
||||
{
|
||||
mixer.music = None;
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
} else {
|
||||
data.fill(0.0);
|
||||
}
|
||||
},
|
||||
|e| eprintln!("Horizon: audio stream error: {e}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
Ok((stream, rate))
|
||||
Ok(stream)
|
||||
}
|
||||
fn random(&mut self) -> u64 {
|
||||
self.seed ^= self.seed << 13;
|
||||
self.seed ^= self.seed >> 7;
|
||||
self.seed ^= self.seed << 17;
|
||||
self.seed
|
||||
}
|
||||
fn resolve(&mut self, event: &str, depth: u32) -> Option<Variant> {
|
||||
let variants = self.events.get(event)?.clone();
|
||||
let total: u32 = variants.iter().map(|v| v.weight).sum();
|
||||
if total == 0 {
|
||||
return None;
|
||||
fn bound(&mut self) -> Bound<'_, MixerBackend> {
|
||||
Bound {
|
||||
engine: &mut self.engine,
|
||||
backend: &mut self.backend,
|
||||
}
|
||||
let mut pick = (self.random() % u64::from(total)) as u32;
|
||||
let chosen = variants.into_iter().find(|v| {
|
||||
if pick < v.weight {
|
||||
true
|
||||
} else {
|
||||
pick -= v.weight;
|
||||
false
|
||||
}
|
||||
/// `SoundManager.updateSource`: the camera position and yaw.
|
||||
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32) {
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.listener = position;
|
||||
m.listener_yaw = yaw;
|
||||
}
|
||||
}
|
||||
/// Sync the volume options, refreshing the categories that changed
|
||||
/// (the option sliders' `refreshCategoryVolume`).
|
||||
fn sync_volumes(&mut self) {
|
||||
let mut volumes = [self.master; 11];
|
||||
for (i, v) in volumes.iter_mut().enumerate().skip(1) {
|
||||
*v = crate::settings::source_volume(i);
|
||||
}
|
||||
for source in 0..11 {
|
||||
if self.engine.source_volumes[source] != volumes[source] {
|
||||
self.engine.source_volumes[source] = volumes[source];
|
||||
self.engine
|
||||
.refresh_category_volume(source, &mut self.backend);
|
||||
}
|
||||
})?;
|
||||
if chosen.is_event && depth < 4 {
|
||||
let name = if chosen.name.contains(':') {
|
||||
chosen.name.clone()
|
||||
} else {
|
||||
format!("minecraft:{}", chosen.name)
|
||||
};
|
||||
let mut inner = self.resolve(&name, depth + 1)?;
|
||||
inner.volume *= chosen.volume;
|
||||
inner.pitch *= chosen.pitch;
|
||||
return Some(inner);
|
||||
}
|
||||
Some(chosen)
|
||||
self.engine.listening = self.record_heard;
|
||||
}
|
||||
/// Play a sound event at a world position (None = non-positional UI sound).
|
||||
/// `SoundManager.tick(paused)`, once per client tick.
|
||||
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
}
|
||||
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
|
||||
pub fn pause(&mut self) {
|
||||
self.engine.pause_all_except(
|
||||
&[crate::sound_engine::MUSIC, crate::sound_engine::UI],
|
||||
&mut self.backend,
|
||||
);
|
||||
}
|
||||
/// `SoundManager.resume`.
|
||||
pub fn resume(&mut self) {
|
||||
self.engine.resume(&mut self.backend);
|
||||
}
|
||||
/// `SoundManager.stop()`: every sound.
|
||||
pub fn stop_all(&mut self) {
|
||||
self.engine.stop_all(&mut self.backend);
|
||||
}
|
||||
/// 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`).
|
||||
pub fn play(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
|
||||
let seed = self.random.next_long();
|
||||
self.play_seeded(event, at, volume, pitch, seed);
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
event: &str,
|
||||
at: Option<[f64; 3]>,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
seed: i64,
|
||||
) {
|
||||
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
}
|
||||
let Some(variant) = self.resolve(event, 0) else {
|
||||
return;
|
||||
};
|
||||
if self.record_heard
|
||||
&& let Some(key) = self.subtitles.get(event)
|
||||
{
|
||||
let range = if at.is_some() {
|
||||
variant.attenuation * volume.max(1.0)
|
||||
} else {
|
||||
f32::INFINITY
|
||||
};
|
||||
self.heard.push(Heard {
|
||||
subtitle: key.clone(),
|
||||
at,
|
||||
range,
|
||||
looping: false,
|
||||
});
|
||||
}
|
||||
if self._stream.is_none() {
|
||||
return;
|
||||
}
|
||||
let file = variant.name.trim_start_matches("minecraft:").to_owned();
|
||||
let entry = self.cache.entry(file.clone()).or_insert_with(|| {
|
||||
decode(
|
||||
&self
|
||||
.root
|
||||
.join(format!("assets/minecraft/sounds/{file}.ogg")),
|
||||
)
|
||||
.map(|(s, r)| (Arc::new(s), r))
|
||||
});
|
||||
let Some((samples, rate)) = entry.clone() else {
|
||||
return;
|
||||
};
|
||||
self.sync_volumes();
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let source = self
|
||||
.source_override
|
||||
.unwrap_or_else(|| crate::settings::sound_source(event));
|
||||
let volume = (volume * variant.volume).clamp(0.0, 1.0)
|
||||
* self.master
|
||||
* crate::settings::source_volume(source);
|
||||
let pitch = (pitch * variant.pitch).clamp(0.5, 2.0);
|
||||
let (mut left, mut right) = (volume, volume);
|
||||
if let Some(p) = at {
|
||||
let d = [
|
||||
p[0] - self.listener[0],
|
||||
p[1] - self.listener[1],
|
||||
p[2] - self.listener[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let range = variant.attenuation * volume.max(1.0);
|
||||
let gain = (1.0 - distance / range).clamp(0.0, 1.0);
|
||||
if gain <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let yaw = self.listener_yaw.to_radians();
|
||||
// Right vector of a Minecraft yaw.
|
||||
let side = (-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32
|
||||
/ distance.max(0.001);
|
||||
let pan = side.clamp(-1.0, 1.0);
|
||||
left = volume * gain * (1.0 - pan.max(0.0) * 0.6);
|
||||
right = volume * gain * (1.0 + pan.min(0.0) * 0.6);
|
||||
}
|
||||
let step = f64::from(rate) / f64::from(self.rate) * f64::from(pitch);
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
if m.voices.len() > 64 {
|
||||
m.voices.remove(0);
|
||||
}
|
||||
m.voices.push(Voice {
|
||||
samples,
|
||||
position: 0.0,
|
||||
step,
|
||||
left,
|
||||
right,
|
||||
event: event.into(),
|
||||
.unwrap_or_else(|| crate::settings::sound_source(&event));
|
||||
let random = LegacyRandom::new(seed);
|
||||
let instance = match at {
|
||||
Some(at) => SoundInstance::at(&event, source, volume, pitch, random, at),
|
||||
None => SoundInstance::new(
|
||||
&event,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `SoundManager.play` of a music instance (`SimpleSoundInstance.forMusic`:
|
||||
/// non-positional, streamed). Decoding runs on a worker thread; the track
|
||||
/// counts as active from now on. Returns false when nothing can play.
|
||||
pub fn play_music(&mut self, event: &str) -> bool {
|
||||
self.stop_music();
|
||||
if self._stream.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(variant) = self.resolve(event, 0) else {
|
||||
return false;
|
||||
volume,
|
||||
pitch,
|
||||
random,
|
||||
false,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
true,
|
||||
),
|
||||
};
|
||||
let path = self.root.join(format!(
|
||||
"assets/minecraft/sounds/{}.ogg",
|
||||
variant.name.trim_start_matches("minecraft:")
|
||||
));
|
||||
let volume =
|
||||
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
|
||||
let step_scale = f64::from(variant.pitch.clamp(0.5, 2.0)) / f64::from(self.rate);
|
||||
let ready = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let (mixer, done) = (self.mixer.clone(), ready.clone());
|
||||
let music_event = event.to_owned();
|
||||
std::thread::spawn(move || {
|
||||
if let Some((samples, rate)) = decode(&path)
|
||||
&& let Ok(mut m) = mixer.lock()
|
||||
&& !done.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
m.music = Some(Voice {
|
||||
samples: Arc::new(samples),
|
||||
position: 0.0,
|
||||
step: f64::from(rate) * step_scale,
|
||||
left: volume,
|
||||
right: volume,
|
||||
event: music_event.as_str().into(),
|
||||
source: 1,
|
||||
});
|
||||
}
|
||||
done.store(true, std::sync::atomic::Ordering::Release);
|
||||
});
|
||||
self.music = Some(MusicTrack {
|
||||
event: event.to_owned(),
|
||||
ready,
|
||||
});
|
||||
true
|
||||
}
|
||||
/// `SoundManager.stop` of the music instance.
|
||||
pub fn stop_music(&mut self) {
|
||||
if let Some(track) = self.music.take() {
|
||||
// A decoder still running sees the flag and drops its result.
|
||||
track
|
||||
.ready
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.music = None;
|
||||
}
|
||||
}
|
||||
/// `SoundManager.isActive`: decoding or still sounding.
|
||||
pub fn music_active(&mut self) -> bool {
|
||||
let Some(track) = &self.music else {
|
||||
return false;
|
||||
};
|
||||
if !track.ready.load(std::sync::atomic::Ordering::Acquire) {
|
||||
return true;
|
||||
}
|
||||
let playing = self.mixer.lock().is_ok_and(|m| m.music.is_some());
|
||||
if !playing {
|
||||
self.music = None;
|
||||
}
|
||||
playing
|
||||
}
|
||||
/// The event of the current music track.
|
||||
pub fn music_event(&self) -> Option<&str> {
|
||||
self.music.as_ref().map(|t| t.event.as_str())
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.events.keys().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
self.play_instance(instance, false);
|
||||
}
|
||||
/// Play a sound on a given `SoundSource` index (`/playsound`).
|
||||
pub fn play_on(
|
||||
@@ -486,102 +507,111 @@ impl Audio {
|
||||
self.play(event, at, volume, pitch);
|
||||
self.source_override = None;
|
||||
}
|
||||
/// `SimpleSoundInstance.forJukeboxSong` at a jukebox; the handle stays
|
||||
/// until `release`.
|
||||
pub fn play_jukebox_song(&mut self, event: &str, pos: [i32; 3]) -> InstanceId {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
|
||||
self.play_instance(instance, true).0
|
||||
}
|
||||
/// Stop and drop a held instance.
|
||||
pub fn stop_and_release(&mut self, id: InstanceId) {
|
||||
self.stop_instance(id);
|
||||
self.release(id);
|
||||
}
|
||||
/// Every sound event id (for command completion).
|
||||
pub fn event_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// `ClientboundStopSoundPacket`: stop the playing sounds of an event
|
||||
/// and/or a source.
|
||||
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
|
||||
let matches = |v: &Voice| {
|
||||
event.is_none_or(|e| &*v.event == e) && source.is_none_or(|s| v.source == s)
|
||||
};
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.voices.retain(|v| !matches(v));
|
||||
m.tracked.retain(|t| !matches(&t.voice));
|
||||
if m.music.as_ref().is_some_and(matches) {
|
||||
m.music = None;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.music
|
||||
.as_ref()
|
||||
.is_some_and(|t| event.is_none_or(|e| t.event == e) && source.is_none_or(|s| s == 1))
|
||||
{
|
||||
self.music = None;
|
||||
}
|
||||
let event = event.map(crate::sound_events::normalize);
|
||||
self.engine
|
||||
.stop_matching(event.as_deref(), source, &mut self.backend);
|
||||
}
|
||||
/// The sounds played since the last call, for the subtitle overlay.
|
||||
pub fn take_heard(&mut self) -> Vec<Heard> {
|
||||
std::mem::take(&mut self.heard)
|
||||
}
|
||||
/// `SoundManager.updateCategoryVolume(MUSIC, gain)`.
|
||||
pub fn set_music_gain(&mut self, gain: f32) {
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.music_gain = gain;
|
||||
}
|
||||
}
|
||||
/// Play a relative (non-positional) tickable sound instance the caller
|
||||
/// keeps a handle to: ambient loops (`looping`) and sub-sounds.
|
||||
pub fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
|
||||
let variant = self.resolve(event, 0)?;
|
||||
if self.record_heard
|
||||
&& let Some(key) = self.subtitles.get(event)
|
||||
{
|
||||
self.heard.push(Heard {
|
||||
subtitle: key.clone(),
|
||||
at: None,
|
||||
range: f32::INFINITY,
|
||||
looping,
|
||||
});
|
||||
}
|
||||
self._stream.as_ref()?;
|
||||
let file = variant.name.trim_start_matches("minecraft:").to_owned();
|
||||
let root = &self.root;
|
||||
let (samples, rate) = self
|
||||
.cache
|
||||
.entry(file.clone())
|
||||
.or_insert_with(|| {
|
||||
decode(&root.join(format!("assets/minecraft/sounds/{file}.ogg")))
|
||||
.map(|(s, r)| (Arc::new(s), r))
|
||||
std::mem::take(&mut self.engine.notifications)
|
||||
.into_iter()
|
||||
.filter_map(|n| {
|
||||
Some(Heard {
|
||||
subtitle: n.subtitle?,
|
||||
at: Some(n.position),
|
||||
range: n.range,
|
||||
looping: n.looping,
|
||||
})
|
||||
})
|
||||
.clone()?;
|
||||
let gain =
|
||||
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
|
||||
let step =
|
||||
f64::from(rate) / f64::from(self.rate) * f64::from(variant.pitch.clamp(0.5, 2.0));
|
||||
self.next_tracked += 1;
|
||||
let id = self.next_tracked;
|
||||
let mut m = self.mixer.lock().ok()?;
|
||||
m.tracked.push(Tracked {
|
||||
id,
|
||||
voice: Voice {
|
||||
samples,
|
||||
position: 0.0,
|
||||
step,
|
||||
left: gain,
|
||||
right: gain,
|
||||
event: event.into(),
|
||||
source: crate::settings::sound_source(event),
|
||||
},
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
impl Sounds for Audio {
|
||||
fn engine(&mut self) -> &mut SoundEngine {
|
||||
&mut self.engine
|
||||
}
|
||||
fn play_instance(&mut self, instance: SoundInstance, retain: bool) -> (InstanceId, PlayResult) {
|
||||
self.sync_volumes();
|
||||
self.bound().play_instance(instance, retain)
|
||||
}
|
||||
fn play_id(&mut self, id: InstanceId) -> PlayResult {
|
||||
self.sync_volumes();
|
||||
self.bound().play_id(id)
|
||||
}
|
||||
fn stop_instance(&mut self, id: InstanceId) {
|
||||
self.bound().stop_instance(id);
|
||||
}
|
||||
fn update_category_volume(&mut self, source: usize, gain: f32) {
|
||||
self.bound().update_category_volume(source, gain);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
|
||||
Channel {
|
||||
samples: Some((Arc::new(samples), 48_000)),
|
||||
position: 0.0,
|
||||
state: State::Playing,
|
||||
gain: 1.0,
|
||||
pitch: 1.0,
|
||||
linear,
|
||||
looping,
|
||||
volume: volume.clamp(0.0, 1.0),
|
||||
});
|
||||
Some(id)
|
||||
}
|
||||
/// A tracked instance's volume (`AbstractTickableSoundInstance.volume`).
|
||||
pub fn set_tracked_volume(&mut self, id: u64, volume: f32) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(t) = m.tracked.iter_mut().find(|t| t.id == id)
|
||||
{
|
||||
t.volume = volume.clamp(0.0, 1.0);
|
||||
at,
|
||||
relative: false,
|
||||
}
|
||||
}
|
||||
pub fn stop_tracked(&mut self, id: u64) {
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.tracked.retain(|t| t.id != id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_attenuation_reaches_zero_at_the_max_distance() {
|
||||
assert_eq!(distance_gain(None, 100.0), 1.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 0.0), 1.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 8.0), 0.5);
|
||||
assert_eq!(distance_gain(Some(16.0), 16.0), 0.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 40.0), 0.0);
|
||||
}
|
||||
/// Still sounding (a finished one-shot is gone).
|
||||
pub fn tracked_active(&self, id: u64) -> bool {
|
||||
self.mixer
|
||||
.lock()
|
||||
.is_ok_and(|m| m.tracked.iter().any(|t| t.id == id))
|
||||
|
||||
#[test]
|
||||
fn sources_play_attenuated_then_stop_or_loop() {
|
||||
let mut m = Mixer::default();
|
||||
m.channels
|
||||
.insert(1, channel(vec![1.0; 4], Some(16.0), [8.0, 0.0, 0.0], false));
|
||||
m.channels
|
||||
.insert(2, channel(vec![1.0; 4], None, [0.0; 3], true));
|
||||
let mut data = [0.0f32; 16];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(m.channels[&1].state, State::Stopped);
|
||||
assert_eq!(m.channels[&2].state, State::Playing);
|
||||
// Mono sum of the two at the first frame: the far one is half as loud.
|
||||
let first = data[0] + data[1];
|
||||
assert!(first > 1.4 && first < 3.1, "{first}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Auto-jump (`LocalPlayer.updateAutoJump` and `canAutoJump`): after the
|
||||
//! local player's own move, look ahead along the movement for a step that
|
||||
//! the player can clear with a jump and is not walled in above; if found, the
|
||||
//! next `aiStep` presses jump for one tick (`autoJumpTime = 1`).
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Axis, Vec3, VoxelShape, trig},
|
||||
position::BlockPos,
|
||||
};
|
||||
|
||||
/// `Float.MIN_VALUE`, the "no obstacle" marker.
|
||||
const NO_OBSTACLE: f32 = f32::from_bits(1);
|
||||
|
||||
/// What `updateAutoJump` reads from the player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AutoJumper {
|
||||
pub position: Vec3,
|
||||
/// `getBoundingBox().maxY`.
|
||||
pub max_y: f64,
|
||||
pub x_rot: f32,
|
||||
pub y_rot: f32,
|
||||
/// `getSpeed()`.
|
||||
pub speed: f32,
|
||||
/// `input.getMoveVector()` (x: strafe, y: forward).
|
||||
pub move_vector: [f32; 2],
|
||||
/// `getBbWidth()`, `getBbHeight()`.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// The jump boost amplifier, when the effect is active.
|
||||
pub jump_boost: Option<i32>,
|
||||
}
|
||||
|
||||
/// The level queries, with the player's collision context.
|
||||
pub trait AutoJumpWorld {
|
||||
type Error;
|
||||
/// `getBlockState(p).getCollisionShape(level, p, CollisionContext.of(player))`.
|
||||
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, Self::Error>;
|
||||
/// `level.getCollisions(player, bounds)` flattened with `toAabbs`, in order.
|
||||
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, Self::Error>;
|
||||
}
|
||||
|
||||
/// `LocalPlayer.canAutoJump`. `staying_on_surface` is
|
||||
/// `isStayingOnGroundSurface()` (the shift key).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn can_auto_jump(
|
||||
enabled: bool,
|
||||
auto_jump_time: i32,
|
||||
on_ground: bool,
|
||||
staying_on_surface: bool,
|
||||
passenger: bool,
|
||||
move_vector: [f32; 2],
|
||||
block_jump_factor: f32,
|
||||
) -> bool {
|
||||
enabled
|
||||
&& auto_jump_time <= 0
|
||||
&& on_ground
|
||||
&& !staying_on_surface
|
||||
&& !passenger
|
||||
&& move_vector[0] * move_vector[0] + move_vector[1] * move_vector[1] > 0.0
|
||||
&& f64::from(block_jump_factor) >= 1.0
|
||||
}
|
||||
|
||||
/// `Vec3.directionFromRotation(rotX, rotY)`.
|
||||
pub fn direction_from_rotation(rot_x: f32, rot_y: f32) -> Vec3 {
|
||||
let rad = trig::DEG_TO_RAD;
|
||||
let pi = std::f32::consts::PI;
|
||||
let y_cos = trig::cos(f64::from(-rot_y * rad - pi));
|
||||
let y_sin = trig::sin(f64::from(-rot_y * rad - pi));
|
||||
let x_cos = -trig::cos(f64::from(-rot_x * rad));
|
||||
let x_sin = trig::sin(f64::from(-rot_x * rad));
|
||||
Vec3::new(
|
||||
f64::from(y_sin * x_cos),
|
||||
f64::from(x_sin),
|
||||
f64::from(y_cos * x_cos),
|
||||
)
|
||||
}
|
||||
|
||||
/// `AABB.intersects(Vec3, Vec3)`: the box overlaps the segment's bounds.
|
||||
fn intersects_segment(b: Aabb, from: Vec3, to: Vec3) -> bool {
|
||||
b.intersects(Aabb::new(from, to))
|
||||
}
|
||||
|
||||
/// `LocalPlayer.updateAutoJump(xa, za)` once `canAutoJump` holds: whether
|
||||
/// it sets `autoJumpTime = 1`. `xa`/`za` are the float horizontal
|
||||
/// displacement of the move.
|
||||
pub fn update_auto_jump<W: AutoJumpWorld>(
|
||||
world: &mut W,
|
||||
p: &AutoJumper,
|
||||
xa: f32,
|
||||
za: f32,
|
||||
) -> Result<bool, W::Error> {
|
||||
let move_begin = p.position;
|
||||
let move_end = move_begin.add_vector(Vec3::new(f64::from(xa), 0.0, f64::from(za)));
|
||||
let mut move_diff = Vec3::new(f64::from(xa), 0.0, f64::from(za));
|
||||
let current_speed = p.speed;
|
||||
let mut move_dist_sq = move_diff.length_squared() as f32;
|
||||
if move_dist_sq <= 0.001 {
|
||||
let input_xa = current_speed * p.move_vector[0];
|
||||
let input_za = current_speed * p.move_vector[1];
|
||||
let sin = trig::sin(f64::from(p.y_rot * trig::DEG_TO_RAD));
|
||||
let cos = trig::cos(f64::from(p.y_rot * trig::DEG_TO_RAD));
|
||||
move_diff = Vec3::new(
|
||||
f64::from(input_xa * cos - input_za * sin),
|
||||
move_diff.y,
|
||||
f64::from(input_za * cos + input_xa * sin),
|
||||
);
|
||||
move_dist_sq = move_diff.length_squared() as f32;
|
||||
if move_dist_sq <= 0.001 {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
// Mth.invSqrt: 1.0F / (float) Math.sqrt(x).
|
||||
let inverted = 1.0 / move_dist_sq.sqrt();
|
||||
let move_dir = move_diff.scale(f64::from(inverted));
|
||||
let facing = direction_from_rotation(p.x_rot, p.y_rot);
|
||||
let facing_vs_moving = (facing.x * move_dir.x + facing.z * move_dir.z) as f32;
|
||||
if facing_vs_moving < -0.15 {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut ceiling = BlockPos::containing(Vec3::new(move_begin.x, p.max_y, move_begin.z));
|
||||
if !world.collision_shape(ceiling)?.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
ceiling = ceiling.offset(0, 1, 0);
|
||||
if !world.collision_shape(ceiling)?.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut jump_height = 1.2f32;
|
||||
if let Some(amplifier) = p.jump_boost {
|
||||
jump_height += (amplifier + 1) as f32 * 0.75;
|
||||
}
|
||||
let look_ahead = crate::camera::java_max(current_speed * 7.0, 1.0 / inverted);
|
||||
let seg_begin = move_begin;
|
||||
let seg_end = move_end.add_vector(move_dir.scale(f64::from(look_ahead)));
|
||||
let (width, height) = (p.width, p.height);
|
||||
let test_box = Aabb::new(
|
||||
seg_begin,
|
||||
seg_end.add_vector(Vec3::new(0.0, f64::from(height), 0.0)),
|
||||
)
|
||||
.inflate(Vec3::new(f64::from(width), 0.0, f64::from(width)));
|
||||
let lift = Vec3::new(0.0, f64::from(0.51f32), 0.0);
|
||||
let seg_begin = seg_begin.add_vector(lift);
|
||||
let seg_end = seg_end.add_vector(lift);
|
||||
let right_dir = move_dir.cross(Vec3::new(0.0, 1.0, 0.0));
|
||||
let right_offset = right_dir.scale(f64::from(width * 0.5));
|
||||
let left_begin = seg_begin.subtract(right_offset);
|
||||
let left_end = seg_end.subtract(right_offset);
|
||||
let right_begin = seg_begin.add_vector(right_offset);
|
||||
let right_end = seg_end.add_vector(right_offset);
|
||||
let mut obstacle = NO_OBSTACLE;
|
||||
for b in world.collision_boxes(test_box)? {
|
||||
if intersects_segment(b, left_begin, left_end)
|
||||
|| intersects_segment(b, right_begin, right_end)
|
||||
{
|
||||
obstacle = b.max_position().y as f32;
|
||||
let obstacle_pos = BlockPos::containing(b.center());
|
||||
let mut steps = 1i32;
|
||||
while (steps as f32) < jump_height {
|
||||
let above = obstacle_pos.offset(0, steps, 0);
|
||||
let shape = world.collision_shape(above)?;
|
||||
if !shape.is_empty() {
|
||||
obstacle = shape.max(Axis::Y) as f32 + above.y as f32;
|
||||
if f64::from(obstacle) - p.position.y > f64::from(jump_height) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
if steps > 1 {
|
||||
ceiling = ceiling.offset(0, 1, 0);
|
||||
if !world.collision_shape(ceiling)?.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
steps += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if obstacle != NO_OBSTACLE {
|
||||
let y_delta = (f64::from(obstacle) - p.position.y) as f32;
|
||||
// Negated as in LocalPlayer.updateAutoJump, so a NaN delta still jumps.
|
||||
#[allow(clippy::neg_cmp_op_on_partial_ord)]
|
||||
let jumps = !(y_delta <= 0.5) && !(y_delta > jump_height);
|
||||
if jumps {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
struct Step;
|
||||
impl AutoJumpWorld for Step {
|
||||
type Error = ();
|
||||
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, ()> {
|
||||
Ok(if p.x == 1 && p.y == 0 {
|
||||
VoxelShape::block()
|
||||
} else {
|
||||
VoxelShape::empty()
|
||||
})
|
||||
}
|
||||
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, ()> {
|
||||
let b = Aabb::new(Vec3::new(1.0, 0.0, -5.0), Vec3::new(2.0, 1.0, 5.0));
|
||||
Ok(if b.intersects(bounds) {
|
||||
vec![b]
|
||||
} else {
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn walking_into_a_one_block_step_jumps() {
|
||||
let p = AutoJumper {
|
||||
position: Vec3::new(0.6, 0.0, 0.5),
|
||||
max_y: 1.8,
|
||||
x_rot: 0.0,
|
||||
y_rot: -90.0,
|
||||
speed: 0.1,
|
||||
move_vector: [0.0, 1.0],
|
||||
width: 0.6,
|
||||
height: 1.8,
|
||||
jump_boost: None,
|
||||
};
|
||||
assert!(update_auto_jump(&mut Step, &p, 0.1, 0.0).unwrap());
|
||||
// Moving away from the step does not.
|
||||
assert!(!update_auto_jump(&mut Step, &p, -0.1, 0.0).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn sneaking_or_standing_still_cannot_auto_jump() {
|
||||
assert!(can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 1.0));
|
||||
assert!(!can_auto_jump(true, 0, true, true, false, [0.0, 1.0], 1.0));
|
||||
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 0.0], 1.0));
|
||||
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 0.5));
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
#![forbid(unsafe_code)]
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
os::unix::net::UnixStream,
|
||||
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:
|
||||
|
||||
@@ -17,11 +17,16 @@ pub struct SpawnEntry {
|
||||
pub weight: u32,
|
||||
pub min: u32,
|
||||
pub max: u32,
|
||||
/// A `ConstantInt` count (sampling draws nothing).
|
||||
pub constant: bool,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeInfo {
|
||||
/// `natural_mob_spawns` by category (creature, monster, ...).
|
||||
pub spawns: HashMap<String, Vec<SpawnEntry>>,
|
||||
/// `natural_mob_spawns` spawn costs by entity type.
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
pub name: String,
|
||||
pub temperature: f32,
|
||||
@@ -103,37 +108,26 @@ impl Biomes {
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let categories = &attributes["minecraft:gameplay/natural_mob_spawns"]["argument"]["spawns_by_category"];
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = entries
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let (min, max) = match &e["count"] {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let list = crate::mobs::spawn_selection::parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
creature_probability: v["creature_spawn_probability"].as_f64().unwrap_or(0.1)
|
||||
as f32,
|
||||
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
temperature,
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
|
||||
@@ -0,0 +1,968 @@
|
||||
//! Client-ticked block-entity animation state, as vanilla's block entities
|
||||
//! keep it: `ChestLidController` (chests, trapped and copper chests, ender
|
||||
//! chests), `EnchantingTableBlockEntity.bookAnimationTick`,
|
||||
//! `ShulkerBoxBlockEntity` (open count, animation status, progress),
|
||||
//! `BellBlockEntity` (ring ticks, shake direction, resonation),
|
||||
//! `ConduitBlockEntity.clientTick` (tick count, shape check, rotation,
|
||||
//! hunting, nautilus particles), `SkullBlockEntity.animation` (powered dragon
|
||||
//! and piglin heads) and `DecoratedPotBlockEntity` wobbles.
|
||||
//!
|
||||
//! [`BlockEntityAnims`] holds the state per position and turns it into the
|
||||
//! renderer's [`BlockAnim`]. Its tick methods take what the level answers
|
||||
//! (blocks, entities, game time) as closures, so the parity fixture can drive
|
||||
//! them with scripted inputs (`tools/test_block_entity_anim_parity.py`).
|
||||
use crate::block_entity_render::BlockAnim;
|
||||
use horizon_core::{
|
||||
geometry::trig::{self, Mth},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `Direction` names by `get3DDataValue`.
|
||||
pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
|
||||
|
||||
fn step(direction: u8) -> [i32; 3] {
|
||||
match direction {
|
||||
0 => [0, -1, 0],
|
||||
1 => [0, 1, 0],
|
||||
2 => [0, 0, -1],
|
||||
3 => [0, 0, 1],
|
||||
4 => [-1, 0, 0],
|
||||
_ => [1, 0, 0],
|
||||
}
|
||||
}
|
||||
|
||||
/// `Direction.from3DDataValue`: `BY_3D_DATA[Mth.abs(data % 6)]`.
|
||||
pub fn from_3d_data_value(data: i32) -> u8 {
|
||||
(data % 6).unsigned_abs() as u8
|
||||
}
|
||||
|
||||
/// The 3D data value of a direction name.
|
||||
pub fn direction_index(name: &str) -> Option<u8> {
|
||||
DIRECTIONS.iter().position(|d| *d == name).map(|i| i as u8)
|
||||
}
|
||||
|
||||
/// `ChestLidController`, shared by `ChestBlockEntity` (chests, trapped and
|
||||
/// copper chests) and `EnderChestBlockEntity`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct ChestLid {
|
||||
pub should_be_open: bool,
|
||||
pub openness: f32,
|
||||
pub o_openness: f32,
|
||||
}
|
||||
|
||||
impl ChestLid {
|
||||
/// `triggerEvent(1, openCount)`: the lid opens while anyone has it open.
|
||||
pub fn trigger_event(&mut self, b0: i32, b1: i32) -> bool {
|
||||
if b0 == 1 {
|
||||
self.should_be_open = b1 > 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// `tickLid` (the client ticker `lidAnimateTick`).
|
||||
pub fn tick(&mut self) {
|
||||
self.o_openness = self.openness;
|
||||
if !self.should_be_open && self.openness > 0.0 {
|
||||
self.openness = (self.openness - 0.1).max(0.0);
|
||||
} else if self.should_be_open && self.openness < 1.0 {
|
||||
self.openness = (self.openness + 0.1).min(1.0);
|
||||
}
|
||||
}
|
||||
/// `getOpenness(a)`: `Mth.lerp(a, oOpenness, openness)`.
|
||||
pub fn get_openness(&self, a: f32) -> f32 {
|
||||
self.o_openness + a * (self.openness - self.o_openness)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestBlock.opennessCombiner`: a double chest opens as far as its more
|
||||
/// open half (`Math.max(first, second)`, first being the `right` half).
|
||||
pub fn combined_openness(first: f32, second: Option<f32>) -> f32 {
|
||||
match second {
|
||||
Some(second) => java_max(first, second),
|
||||
None => first,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Math.max(float, float)`.
|
||||
fn java_max(a: f32, b: f32) -> f32 {
|
||||
if a.is_nan() {
|
||||
a
|
||||
} else if a == 0.0 && b == 0.0 && a.to_bits() != b.to_bits() {
|
||||
0.0
|
||||
} else if a >= b {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestRenderer.submit` and `ChestModel.setupAnim`: the lid and lock
|
||||
/// `xRot` for a render-state openness.
|
||||
pub fn chest_lid_x_rot(open: f32) -> f32 {
|
||||
let open = 1.0 - open;
|
||||
let open = 1.0 - open * open * open;
|
||||
-(open * std::f32::consts::FRAC_PI_2)
|
||||
}
|
||||
|
||||
/// `EnchantingTableBlockEntity` book fields.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct EnchantingBook {
|
||||
pub time: i32,
|
||||
pub flip: f32,
|
||||
pub o_flip: f32,
|
||||
pub flip_t: f32,
|
||||
pub flip_a: f32,
|
||||
pub open: f32,
|
||||
pub o_open: f32,
|
||||
pub rot: f32,
|
||||
pub o_rot: f32,
|
||||
pub t_rot: f32,
|
||||
}
|
||||
|
||||
/// The `while (a >= PI) a -= 2PI; while (a < -PI) a += 2PI` loops.
|
||||
fn wrap_pi(mut a: f32) -> f32 {
|
||||
use std::f32::consts::PI;
|
||||
while a >= PI {
|
||||
a -= 2.0 * PI;
|
||||
}
|
||||
while a < -PI {
|
||||
a += 2.0 * PI;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// `Mth.floor(float)`.
|
||||
fn mth_floor(v: f32) -> i32 {
|
||||
let i = v as i32;
|
||||
if v < i as f32 { i - 1 } else { i }
|
||||
}
|
||||
|
||||
/// `Mth.frac(float)`.
|
||||
pub fn mth_frac(v: f32) -> f32 {
|
||||
v - mth_floor(v) as f32
|
||||
}
|
||||
|
||||
/// `EnchantTableRenderState` (`EnchantTableRenderer.extractRenderState`) and
|
||||
/// the `BookModel.State` its `submit` builds.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct BookRender {
|
||||
pub flip: f32,
|
||||
pub open: f32,
|
||||
pub time: f32,
|
||||
pub y_rot: f32,
|
||||
/// `BookModel.State.forAnimation`: openness, pageFlip1, pageFlip2.
|
||||
pub openness: f32,
|
||||
pub page_flip1: f32,
|
||||
pub page_flip2: f32,
|
||||
}
|
||||
|
||||
impl EnchantingBook {
|
||||
/// `bookAnimationTick`. `players` are the feet positions of the level's
|
||||
/// non-spectator players (`getNearestPlayer(centre, 3.0, false)`);
|
||||
/// `random` is the block entity class's shared static `RANDOM`.
|
||||
pub fn tick(&mut self, pos: [i32; 3], players: &[[f64; 3]], random: &mut impl RandomSource) {
|
||||
self.o_open = self.open;
|
||||
self.o_rot = self.rot;
|
||||
let c = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let mut best = -1.0;
|
||||
let mut nearest = None;
|
||||
for p in players {
|
||||
let (dx, dy, dz) = (p[0] - c[0], p[1] - c[1], p[2] - c[2]);
|
||||
let dist = dx * dx + dy * dy + dz * dz;
|
||||
if dist < 9.0 && (best == -1.0 || dist < best) {
|
||||
best = dist;
|
||||
nearest = Some(*p);
|
||||
}
|
||||
}
|
||||
if let Some(p) = nearest {
|
||||
let xd = p[0] - c[0];
|
||||
let zd = p[2] - c[2];
|
||||
self.t_rot = trig::atan2(zd, xd) as f32;
|
||||
self.open += 0.1;
|
||||
if self.open < 0.5 || random.next_int_bound(40).unwrap_or(1) == 0 {
|
||||
let old = self.flip_t;
|
||||
loop {
|
||||
let a = random.next_int_bound(4).unwrap_or(0);
|
||||
let b = random.next_int_bound(4).unwrap_or(0);
|
||||
self.flip_t += (a - b) as f32;
|
||||
if old != self.flip_t {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.t_rot += 0.02;
|
||||
self.open -= 0.1;
|
||||
}
|
||||
self.rot = wrap_pi(self.rot);
|
||||
self.t_rot = wrap_pi(self.t_rot);
|
||||
let dir = wrap_pi(self.t_rot - self.rot);
|
||||
self.rot += dir * 0.4;
|
||||
self.open = self.open.clamp(0.0, 1.0);
|
||||
self.time += 1;
|
||||
self.o_flip = self.flip;
|
||||
let diff = ((self.flip_t - self.flip) * 0.4).clamp(-0.2, 0.2);
|
||||
self.flip_a += (diff - self.flip_a) * 0.9;
|
||||
self.flip += self.flip_a;
|
||||
}
|
||||
/// `EnchantTableRenderer.extractRenderState` and the book state of its
|
||||
/// `submit`.
|
||||
pub fn render(&self, a: f32) -> BookRender {
|
||||
let flip = self.o_flip + a * (self.flip - self.o_flip);
|
||||
let open = self.o_open + a * (self.open - self.o_open);
|
||||
let time = self.time as f32 + a;
|
||||
let y_rot = self.o_rot + wrap_pi(self.rot - self.o_rot) * a;
|
||||
let ff1 = mth_frac(flip + 0.25) * 1.6 - 0.3;
|
||||
let ff2 = mth_frac(flip + 0.75) * 1.6 - 0.3;
|
||||
BookRender {
|
||||
flip,
|
||||
open,
|
||||
time,
|
||||
y_rot,
|
||||
openness: ((time * 0.02).msin() * 0.1 + 1.25) * open,
|
||||
page_flip1: ff1.clamp(0.0, 1.0),
|
||||
page_flip2: ff2.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShulkerBoxBlockEntity.AnimationStatus`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ShulkerStatus {
|
||||
#[default]
|
||||
Closed,
|
||||
Opening,
|
||||
Opened,
|
||||
Closing,
|
||||
}
|
||||
|
||||
/// `ShulkerBoxBlockEntity` lid state.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Shulker {
|
||||
pub open_count: i32,
|
||||
pub status: ShulkerStatus,
|
||||
pub progress: f32,
|
||||
pub progress_old: f32,
|
||||
}
|
||||
|
||||
/// Side effects of one [`Shulker::tick`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ShulkerTick {
|
||||
/// `doNeighborUpdates` calls (shape and neighbour updates around the box).
|
||||
pub neighbor_updates: u32,
|
||||
/// `moveCollidedEntities`: the swept lid box (min x, y, z, max x, y, z);
|
||||
/// entities in it move out along the facing.
|
||||
pub push: Option<[f64; 6]>,
|
||||
}
|
||||
|
||||
/// `Shulker.getProgressDeltaAabb(size, direction, from, to, position)`.
|
||||
pub fn progress_delta_aabb(
|
||||
size: f32,
|
||||
direction: u8,
|
||||
from: f32,
|
||||
to: f32,
|
||||
position: [f64; 3],
|
||||
) -> [f64; 6] {
|
||||
let size_d = f64::from(size);
|
||||
let mut b = [
|
||||
-size_d * 0.5,
|
||||
0.0,
|
||||
-size_d * 0.5,
|
||||
size_d * 0.5,
|
||||
size_d,
|
||||
size_d * 0.5,
|
||||
];
|
||||
let max = f64::from(from.max(to));
|
||||
let min = f64::from(from.min(to));
|
||||
let s = step(direction);
|
||||
for axis in 0..3 {
|
||||
// expandTowards
|
||||
let a = f64::from(s[axis]) * max * size_d;
|
||||
if a < 0.0 {
|
||||
b[axis] += a;
|
||||
} else if a > 0.0 {
|
||||
b[axis + 3] += a;
|
||||
}
|
||||
}
|
||||
for axis in 0..3 {
|
||||
// contract
|
||||
let a = -f64::from(s[axis]) * (1.0 + min) * size_d;
|
||||
if a < 0.0 {
|
||||
b[axis] -= a;
|
||||
} else if a > 0.0 {
|
||||
b[axis + 3] -= a;
|
||||
}
|
||||
}
|
||||
// new AABB keeps min <= max; then move to the position.
|
||||
let mut out = [0.0; 6];
|
||||
for axis in 0..3 {
|
||||
out[axis] = b[axis].min(b[axis + 3]) + position[axis];
|
||||
out[axis + 3] = b[axis].max(b[axis + 3]) + position[axis];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Shulker {
|
||||
/// `triggerEvent(1, openCount)`.
|
||||
pub fn trigger_event(&mut self, b0: i32, b1: i32) -> bool {
|
||||
if b0 != 1 {
|
||||
return false;
|
||||
}
|
||||
self.open_count = b1;
|
||||
if b1 == 0 {
|
||||
self.status = ShulkerStatus::Closing;
|
||||
}
|
||||
if b1 == 1 {
|
||||
self.status = ShulkerStatus::Opening;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `startOpen`/`stopOpen` for a non-spectator user: the new open count to
|
||||
/// send as block event 1, and whether this was the first opener (open) or
|
||||
/// the last closer (close), which play the sound and game event.
|
||||
pub fn start_open(&mut self) -> (i32, bool) {
|
||||
if self.open_count < 0 {
|
||||
self.open_count = 0;
|
||||
}
|
||||
self.open_count += 1;
|
||||
(self.open_count, self.open_count == 1)
|
||||
}
|
||||
pub fn stop_open(&mut self) -> (i32, bool) {
|
||||
self.open_count -= 1;
|
||||
(self.open_count, self.open_count <= 0)
|
||||
}
|
||||
/// `updateAnimation` (`ShulkerBoxBlockEntity.tick`); `facing` is the
|
||||
/// block's facing as a 3D data value.
|
||||
pub fn tick(&mut self, pos: [i32; 3], facing: u8) -> ShulkerTick {
|
||||
let mut out = ShulkerTick::default();
|
||||
self.progress_old = self.progress;
|
||||
match self.status {
|
||||
ShulkerStatus::Closed => self.progress = 0.0,
|
||||
ShulkerStatus::Opening => {
|
||||
self.progress += 0.1;
|
||||
if self.progress_old == 0.0 {
|
||||
out.neighbor_updates += 1;
|
||||
}
|
||||
if self.progress >= 1.0 {
|
||||
self.status = ShulkerStatus::Opened;
|
||||
self.progress = 1.0;
|
||||
out.neighbor_updates += 1;
|
||||
}
|
||||
let bottom = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
out.push = Some(progress_delta_aabb(
|
||||
1.0,
|
||||
facing,
|
||||
self.progress_old,
|
||||
self.progress,
|
||||
bottom,
|
||||
));
|
||||
}
|
||||
ShulkerStatus::Opened => self.progress = 1.0,
|
||||
ShulkerStatus::Closing => {
|
||||
self.progress -= 0.1;
|
||||
if self.progress_old == 1.0 {
|
||||
out.neighbor_updates += 1;
|
||||
}
|
||||
if self.progress <= 0.0 {
|
||||
self.status = ShulkerStatus::Closed;
|
||||
self.progress = 0.0;
|
||||
out.neighbor_updates += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
|
||||
pub fn get_progress(&self, a: f32) -> f32 {
|
||||
self.progress_old + a * (self.progress - self.progress_old)
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
|
||||
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
|
||||
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
|
||||
let bottom = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let mut b = progress_delta_aabb(1.0, facing, 0.0, 0.5, bottom);
|
||||
for axis in 0..3 {
|
||||
b[axis] += 1.0e-6;
|
||||
b[axis + 3] -= 1.0e-6;
|
||||
}
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// What the bell's tick asks about an entity it gathered when rung.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct BellEntity {
|
||||
pub position: [f64; 3],
|
||||
/// `isAlive() && !isRemoved()`.
|
||||
pub alive: bool,
|
||||
/// In `EntityTypeTags.RAIDERS`.
|
||||
pub raider: bool,
|
||||
}
|
||||
|
||||
/// `BellBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Bell {
|
||||
pub ticks: i32,
|
||||
pub shaking: bool,
|
||||
/// `clickDirection` as a 3D data value.
|
||||
pub click_direction: Option<u8>,
|
||||
pub last_ring_timestamp: i64,
|
||||
/// `nearbyEntities`: the living entities around the bell when it was last
|
||||
/// gathered, as caller-defined handles.
|
||||
pub nearby: Option<Vec<u64>>,
|
||||
pub resonating: bool,
|
||||
pub resonation_ticks: i32,
|
||||
}
|
||||
|
||||
/// Side effects of one [`Bell::tick`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BellTick {
|
||||
/// Resonation started: `BELL_RESONATE` (played by the server).
|
||||
pub resonate: bool,
|
||||
/// Resonation ended (`ResonationEndAction`): the client's
|
||||
/// `showBellParticles` entity-effect particles (position and colour) and
|
||||
/// the server's `makeRaidersGlow` targets.
|
||||
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
|
||||
}
|
||||
|
||||
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
let dx = f64::from(pos[0]) + 0.5 - p[0];
|
||||
let dy = f64::from(pos[1]) + 0.5 - p[1];
|
||||
let dz = f64::from(pos[2]) + 0.5 - p[2];
|
||||
dx * dx + dy * dy + dz * dz < distance * distance
|
||||
}
|
||||
|
||||
impl Bell {
|
||||
/// `triggerEvent(1, direction)`. `gather` lists the living entities whose
|
||||
/// position is inside the bell's block box inflated by 48
|
||||
/// (`updateEntities`, refreshed at most every 60 ticks).
|
||||
pub fn trigger_event(
|
||||
&mut self,
|
||||
b0: i32,
|
||||
b1: i32,
|
||||
game_time: i64,
|
||||
gather: impl FnOnce() -> Vec<u64>,
|
||||
) -> bool {
|
||||
if b0 != 1 {
|
||||
return false;
|
||||
}
|
||||
if game_time > self.last_ring_timestamp + 60 || self.nearby.is_none() {
|
||||
self.last_ring_timestamp = game_time;
|
||||
self.nearby = Some(gather());
|
||||
}
|
||||
self.resonation_ticks = 0;
|
||||
self.click_direction = Some(from_3d_data_value(b1));
|
||||
self.ticks = 0;
|
||||
self.shaking = true;
|
||||
true
|
||||
}
|
||||
/// `BellBlockEntity.tick` with the client's resonation end action.
|
||||
pub fn tick(&mut self, pos: [i32; 3], entity: impl Fn(u64) -> Option<BellEntity>) -> BellTick {
|
||||
let mut out = BellTick::default();
|
||||
if self.shaking {
|
||||
self.ticks += 1;
|
||||
}
|
||||
if self.ticks >= 50 {
|
||||
self.shaking = false;
|
||||
self.ticks = 0;
|
||||
}
|
||||
let nearby = self.nearby.clone().unwrap_or_default();
|
||||
let raider_in = |e: &BellEntity, distance: f64| {
|
||||
e.alive && closer_to_center_than(pos, e.position, distance) && e.raider
|
||||
};
|
||||
if self.ticks >= 5
|
||||
&& self.resonation_ticks == 0
|
||||
&& nearby
|
||||
.iter()
|
||||
.filter_map(|&h| entity(h))
|
||||
.any(|e| raider_in(&e, 32.0))
|
||||
{
|
||||
self.resonating = true;
|
||||
out.resonate = true;
|
||||
}
|
||||
if self.resonating {
|
||||
if self.resonation_ticks < 40 {
|
||||
self.resonation_ticks += 1;
|
||||
} else {
|
||||
// showBellParticles / makeRaidersGlow.
|
||||
let mut color = 16_700_985;
|
||||
let views: Vec<(u64, BellEntity)> = nearby
|
||||
.iter()
|
||||
.filter_map(|&h| Some((h, entity(h)?)))
|
||||
.collect();
|
||||
let count = views
|
||||
.iter()
|
||||
.filter(|(_, e)| closer_to_center_than(pos, e.position, 48.0))
|
||||
.count() as i32;
|
||||
let per = ((count - 21) / -2).clamp(3, 15);
|
||||
let mut particles = Vec::new();
|
||||
let mut glow = Vec::new();
|
||||
for (h, e) in &views {
|
||||
if !raider_in(e, 48.0) {
|
||||
continue;
|
||||
}
|
||||
glow.push(*h);
|
||||
let (bx, bz) = (f64::from(pos[0]), f64::from(pos[2]));
|
||||
let dist = ((e.position[0] - bx) * (e.position[0] - bx)
|
||||
+ (e.position[2] - bz) * (e.position[2] - bz))
|
||||
.sqrt();
|
||||
let x3 = bx + f64::from(0.5f32) + 1.0 / dist * (e.position[0] - bx);
|
||||
let z3 = bz + f64::from(0.5f32) + 1.0 / dist * (e.position[2] - bz);
|
||||
for _ in 0..per {
|
||||
color += 5;
|
||||
particles.push(([x3, f64::from(pos[1]) + 0.5, z3], color));
|
||||
}
|
||||
}
|
||||
out.ended = Some((particles, glow));
|
||||
self.resonating = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The conduit's `destroyTarget` as the client sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConduitTarget {
|
||||
pub position: [f64; 3],
|
||||
pub eye_y: f64,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// `ConduitBlockEntity` (client side).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Conduit {
|
||||
pub tick_count: i32,
|
||||
pub active_rotation: f32,
|
||||
pub is_active: bool,
|
||||
pub is_hunting: bool,
|
||||
pub effect_blocks: Vec<[i32; 3]>,
|
||||
}
|
||||
|
||||
/// One nautilus particle: position and velocity.
|
||||
pub type Particle = ([f64; 3], [f64; 3]);
|
||||
|
||||
impl Conduit {
|
||||
/// `updateShape`: water all around, then the frame blocks
|
||||
/// (`BlockTags.CONDUIT_EFFECT_BLOCK`) in the three 5x5 rings.
|
||||
fn update_shape(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
is_water: &impl Fn([i32; 3]) -> bool,
|
||||
is_frame: &impl Fn([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
self.effect_blocks.clear();
|
||||
for ox in -1..=1 {
|
||||
for oy in -1..=1 {
|
||||
for oz in -1..=1 {
|
||||
if !is_water([pos[0] + ox, pos[1] + oy, pos[2] + oz]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for ox in -2i32..=2 {
|
||||
for oy in -2i32..=2 {
|
||||
for oz in -2i32..=2 {
|
||||
let (ax, ay, az) = (ox.abs(), oy.abs(), oz.abs());
|
||||
if (ax > 1 || ay > 1 || az > 1)
|
||||
&& (ox == 0 && (ay == 2 || az == 2)
|
||||
|| oy == 0 && (ax == 2 || az == 2)
|
||||
|| oz == 0 && (ax == 2 || ay == 2))
|
||||
{
|
||||
let p = [pos[0] + ox, pos[1] + oy, pos[2] + oz];
|
||||
if is_frame(p) {
|
||||
self.effect_blocks.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.effect_blocks.len() >= 16
|
||||
}
|
||||
/// `ConduitBlockEntity.clientTick`: returns the nautilus particles of
|
||||
/// `animationTick`, drawn from the client level's random.
|
||||
pub fn client_tick(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
game_time: i64,
|
||||
is_water: impl Fn([i32; 3]) -> bool,
|
||||
is_frame: impl Fn([i32; 3]) -> bool,
|
||||
target: Option<ConduitTarget>,
|
||||
random: &mut impl RandomSource,
|
||||
) -> Vec<Particle> {
|
||||
self.tick_count += 1;
|
||||
if game_time % 40 == 0 {
|
||||
self.is_active = self.update_shape(pos, &is_water, &is_frame);
|
||||
self.is_hunting = self.effect_blocks.len() >= 42;
|
||||
}
|
||||
let particles = self.animation_tick(pos, target, random);
|
||||
if self.is_active {
|
||||
self.active_rotation += 1.0;
|
||||
}
|
||||
particles
|
||||
}
|
||||
fn animation_tick(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
target: Option<ConduitTarget>,
|
||||
random: &mut impl RandomSource,
|
||||
) -> Vec<Particle> {
|
||||
let mut out = Vec::new();
|
||||
let hh = f64::from(((self.tick_count + 35) as f32 * 0.1).msin() / 2.0 + 0.5);
|
||||
let hh = (hh * hh + hh) * f64::from(0.3f32);
|
||||
let end = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 1.5 + hh,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
for p in &self.effect_blocks {
|
||||
if random.next_int_bound(50).unwrap_or(1) == 0 {
|
||||
let dx = -0.5 + random.next_float() + (p[0] - pos[0]) as f32;
|
||||
let dy = -2.0 + random.next_float() + (p[1] - pos[1]) as f32;
|
||||
let dz = -0.5 + random.next_float() + (p[2] - pos[2]) as f32;
|
||||
out.push((end, [f64::from(dx), f64::from(dy), f64::from(dz)]));
|
||||
}
|
||||
}
|
||||
if let Some(t) = target {
|
||||
let randx = (-0.5 + random.next_float()) * (3.0 + t.width);
|
||||
let randy = -1.0 + random.next_float() * t.height;
|
||||
let randz = (-0.5 + random.next_float()) * (3.0 + t.width);
|
||||
out.push((
|
||||
[t.position[0], t.eye_y, t.position[2]],
|
||||
[f64::from(randx), f64::from(randy), f64::from(randz)],
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Skull {
|
||||
pub animating: bool,
|
||||
pub tick_count: i32,
|
||||
}
|
||||
impl Skull {
|
||||
/// `SkullBlockEntity.animation`.
|
||||
pub fn tick(&mut self, powered: bool) {
|
||||
if powered {
|
||||
self.animating = true;
|
||||
self.tick_count += 1;
|
||||
} else {
|
||||
self.animating = false;
|
||||
}
|
||||
}
|
||||
/// `getAnimation(a)`.
|
||||
pub fn get_animation(&self, a: f32) -> f32 {
|
||||
if self.animating {
|
||||
self.tick_count as f32 + a
|
||||
} else {
|
||||
self.tick_count as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DecoratedPotBlockEntity.WobbleStyle`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WobbleStyle {
|
||||
Positive,
|
||||
Negative,
|
||||
}
|
||||
impl WobbleStyle {
|
||||
pub fn duration(self) -> i32 {
|
||||
match self {
|
||||
Self::Positive => 7,
|
||||
Self::Negative => 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DecoratedPotBlockEntity` wobble fields.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct PotWobble {
|
||||
pub started_at: i64,
|
||||
pub style: Option<WobbleStyle>,
|
||||
}
|
||||
impl PotWobble {
|
||||
/// `triggerEvent(1, style)`.
|
||||
pub fn trigger_event(&mut self, event: i32, data: i32, game_time: i64) -> bool {
|
||||
if event == 1 && (0..2).contains(&data) {
|
||||
self.started_at = game_time;
|
||||
self.style = Some(if data == 0 {
|
||||
WobbleStyle::Positive
|
||||
} else {
|
||||
WobbleStyle::Negative
|
||||
});
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// `DecoratedPotRenderer.extractRenderState`'s `wobbleProgress`.
|
||||
pub fn progress(&self, game_time: i64, partial: f32) -> f32 {
|
||||
match self.style {
|
||||
Some(style) => {
|
||||
((game_time - self.started_at) as f32 + partial) / style.duration() as f32
|
||||
}
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity` fields the beam reads.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub teleport_cooldown: i32,
|
||||
}
|
||||
impl EndGateway {
|
||||
/// `beamAnimationTick` (the client ticker).
|
||||
pub fn tick(&mut self) {
|
||||
self.age = self.age.wrapping_add(1);
|
||||
if self.teleport_cooldown > 0 {
|
||||
self.teleport_cooldown -= 1;
|
||||
}
|
||||
}
|
||||
/// `portalTick` on the server copy: whether it called `triggerCooldown`,
|
||||
/// whose block event 1 reaches the client copy.
|
||||
pub fn portal_tick(&mut self) -> bool {
|
||||
let cooling = self.teleport_cooldown > 0;
|
||||
self.age = self.age.wrapping_add(1);
|
||||
if cooling {
|
||||
self.teleport_cooldown -= 1;
|
||||
false
|
||||
} else if self.age % 2400 == 0 {
|
||||
self.teleport_cooldown = 40;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// `EndGatewayBlock.entityInside` on the server copy: an entity that can
|
||||
/// use the portal starts the cooldown unless it is already running.
|
||||
pub fn enter(&mut self) -> bool {
|
||||
if self.teleport_cooldown > 0 {
|
||||
return false;
|
||||
}
|
||||
self.teleport_cooldown = 40;
|
||||
true
|
||||
}
|
||||
/// `triggerEvent`: event 1 starts the 40 tick cooldown.
|
||||
pub fn trigger_event(&mut self, event: i32, _data: i32) -> bool {
|
||||
if event == 1 {
|
||||
self.teleport_cooldown = 40;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The client ticker a block entity runs (`getTicker` on a client level),
|
||||
/// or [`Ticker::Pot`] for decorated pots, which keep wobble state unticked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Ticker {
|
||||
/// `ChestBlockEntity` / `EnderChestBlockEntity.lidAnimateTick`.
|
||||
Lid,
|
||||
/// `EnchantingTableBlockEntity.bookAnimationTick`.
|
||||
Book,
|
||||
/// `ShulkerBoxBlockEntity.tick`, with the box's facing.
|
||||
Shulker {
|
||||
facing: u8,
|
||||
},
|
||||
/// `BellBlockEntity.clientTick`.
|
||||
Bell,
|
||||
/// `ConduitBlockEntity.clientTick`.
|
||||
Conduit,
|
||||
/// `SkullBlockEntity.animation` (dragon and piglin heads only).
|
||||
Skull {
|
||||
powered: bool,
|
||||
},
|
||||
Pot,
|
||||
/// `TheEndGatewayBlockEntity.beamAnimationTick`.
|
||||
Gateway,
|
||||
}
|
||||
|
||||
/// The [`Ticker`] of a block state, by block name and property lookup.
|
||||
pub fn ticker(name: &str, prop: impl Fn(&str) -> Option<String>) -> Option<Ticker> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
let unwaxed = name.strip_prefix("waxed_").unwrap_or(name);
|
||||
Some(match unwaxed {
|
||||
"chest"
|
||||
| "trapped_chest"
|
||||
| "ender_chest"
|
||||
| "copper_chest"
|
||||
| "exposed_copper_chest"
|
||||
| "weathered_copper_chest"
|
||||
| "oxidized_copper_chest" => Ticker::Lid,
|
||||
"enchanting_table" => Ticker::Book,
|
||||
"bell" => Ticker::Bell,
|
||||
"conduit" => Ticker::Conduit,
|
||||
"decorated_pot" => Ticker::Pot,
|
||||
"end_gateway" => Ticker::Gateway,
|
||||
"dragon_head" | "dragon_wall_head" | "piglin_head" | "piglin_wall_head" => Ticker::Skull {
|
||||
powered: prop("powered").as_deref() == Some("true"),
|
||||
},
|
||||
n if n.ends_with("shulker_box") => Ticker::Shulker {
|
||||
facing: prop("facing")
|
||||
.as_deref()
|
||||
.and_then(direction_index)
|
||||
.unwrap_or(1),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The animated block entities of a level, by position.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BlockEntityAnims {
|
||||
pub chests: HashMap<[i32; 3], ChestLid>,
|
||||
pub books: HashMap<[i32; 3], EnchantingBook>,
|
||||
/// `EnchantingTableBlockEntity.RANDOM`, shared by every table.
|
||||
pub book_random: LegacyRandom,
|
||||
pub shulkers: HashMap<[i32; 3], Shulker>,
|
||||
pub bells: HashMap<[i32; 3], Bell>,
|
||||
pub conduits: HashMap<[i32; 3], Conduit>,
|
||||
pub skulls: HashMap<[i32; 3], Skull>,
|
||||
pub pots: HashMap<[i32; 3], PotWobble>,
|
||||
pub gateways: HashMap<[i32; 3], EndGateway>,
|
||||
/// The integrated server's copies of the gateways (`portalTick`).
|
||||
pub gateway_servers: HashMap<[i32; 3], EndGateway>,
|
||||
/// The client level's random (conduit particles).
|
||||
pub random: LegacyRandom,
|
||||
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
|
||||
pub kinds: Vec<Option<Ticker>>,
|
||||
}
|
||||
impl Default for BlockEntityAnims {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
chests: HashMap::new(),
|
||||
books: HashMap::new(),
|
||||
book_random: LegacyRandom::new(0x626f6f6b),
|
||||
shulkers: HashMap::new(),
|
||||
bells: HashMap::new(),
|
||||
conduits: HashMap::new(),
|
||||
skulls: HashMap::new(),
|
||||
pots: HashMap::new(),
|
||||
gateways: HashMap::new(),
|
||||
gateway_servers: HashMap::new(),
|
||||
random: LegacyRandom::new(0x6c6576656c),
|
||||
kinds: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl BlockEntityAnims {
|
||||
/// Fills [`Self::kinds`] for every state of the registry.
|
||||
pub fn classify(&mut self, registry: &horizon_world::registry::BlockRegistry) {
|
||||
self.kinds = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
registry
|
||||
.state(horizon_world::registry::BlockStateId(id))
|
||||
.and_then(|s| ticker(&s.name, |k| s.properties.get(k).cloned()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
/// The ticker of a block state.
|
||||
pub fn ticker(&self, state: u32) -> Option<Ticker> {
|
||||
self.kinds.get(state as usize).copied().flatten()
|
||||
}
|
||||
/// Drops the state of block entities that are gone or changed kind:
|
||||
/// `present` maps the loaded block entities to their tickers.
|
||||
pub fn retain_present(&mut self, present: &HashMap<[i32; 3], Ticker>) {
|
||||
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| present.get(p).is_some_and(f);
|
||||
self.chests.retain(|p, _| is(p, |t| *t == Ticker::Lid));
|
||||
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
|
||||
self.shulkers
|
||||
.retain(|p, _| is(p, |t| matches!(t, Ticker::Shulker { .. })));
|
||||
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
|
||||
self.conduits
|
||||
.retain(|p, _| is(p, |t| *t == Ticker::Conduit));
|
||||
self.skulls
|
||||
.retain(|p, _| is(p, |t| matches!(t, Ticker::Skull { .. })));
|
||||
self.pots.retain(|p, _| is(p, |t| *t == Ticker::Pot));
|
||||
self.gateways
|
||||
.retain(|p, _| is(p, |t| *t == Ticker::Gateway));
|
||||
}
|
||||
/// Drops the state of positions whose block entity is gone.
|
||||
pub fn retain(&mut self, keep: impl Fn([i32; 3]) -> bool) {
|
||||
self.chests.retain(|p, _| keep(*p));
|
||||
self.books.retain(|p, _| keep(*p));
|
||||
self.shulkers.retain(|p, _| keep(*p));
|
||||
self.bells.retain(|p, _| keep(*p));
|
||||
self.conduits.retain(|p, _| keep(*p));
|
||||
self.skulls.retain(|p, _| keep(*p));
|
||||
self.pots.retain(|p, _| keep(*p));
|
||||
self.gateways.retain(|p, _| keep(*p));
|
||||
self.gateway_servers.retain(|p, _| keep(*p));
|
||||
}
|
||||
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
|
||||
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
|
||||
self.chests
|
||||
.get(&pos)
|
||||
.map_or(0.0, |l| l.get_openness(partial))
|
||||
}
|
||||
/// The renderer's view of the block entity at `pos`; `sherds` are the
|
||||
/// pot's decorations (back, left, right, front), when it is a pot.
|
||||
pub fn block_anim(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
game_time: i64,
|
||||
partial: f32,
|
||||
sherds: [Option<String>; 4],
|
||||
) -> BlockAnim {
|
||||
let mut anim = BlockAnim {
|
||||
sherds,
|
||||
// Gateways not ticked yet render settled (no beam).
|
||||
gateway: self
|
||||
.gateways
|
||||
.get(&pos)
|
||||
.map_or((i64::MAX, 0), |g| (g.age, g.teleport_cooldown)),
|
||||
..BlockAnim::default()
|
||||
};
|
||||
if let Some(b) = self.books.get(&pos) {
|
||||
anim.book = *b;
|
||||
}
|
||||
if let Some(s) = self.shulkers.get(&pos) {
|
||||
anim.shulker = (s.progress, s.progress_old);
|
||||
}
|
||||
if let Some(b) = self.bells.get(&pos) {
|
||||
anim.bell_ticks = b.ticks;
|
||||
anim.bell_shake = b
|
||||
.shaking
|
||||
.then_some(b.click_direction)
|
||||
.flatten()
|
||||
.map(|d| DIRECTIONS[usize::from(d)]);
|
||||
}
|
||||
if let Some(c) = self.conduits.get(&pos) {
|
||||
anim.conduit = (c.tick_count, c.is_active, c.is_hunting, c.active_rotation);
|
||||
}
|
||||
if let Some(s) = self.skulls.get(&pos) {
|
||||
anim.skull = (s.animating, s.tick_count);
|
||||
}
|
||||
if let Some(w) = self.pots.get(&pos)
|
||||
&& w.style.is_some()
|
||||
{
|
||||
// extractRenderState never copies lastWobbleStyle into the render
|
||||
// state, so DecoratedPotRenderer.submit always takes the
|
||||
// non-POSITIVE branch (with the style's own duration).
|
||||
anim.wobble = Some((false, w.progress(game_time, partial)));
|
||||
}
|
||||
anim
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
// The world border wall (core/world_border): camera-relative wall quads
|
||||
// moved by the model offset, the forcefield texture scrolled by the texture
|
||||
// offset and modulated by the status colour and fade.
|
||||
struct Globals {
|
||||
view_proj: mat4x4<f32>,
|
||||
}
|
||||
struct Border {
|
||||
color: vec4<f32>,
|
||||
// xyz: model offset, w: texture translation (u and v)
|
||||
offset: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var forcefield: texture_2d<f32>;
|
||||
@group(0) @binding(2) var forcefield_sampler: sampler;
|
||||
@group(0) @binding(3) var<uniform> border: Border;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
@vertex fn vs_border(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>) -> Out {
|
||||
var out: Out;
|
||||
out.clip = globals.view_proj * vec4(position + border.offset.xyz, 1.0);
|
||||
out.uv = uv + vec2(border.offset.w);
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = textureSample(forcefield, forcefield_sampler, in.uv);
|
||||
if color.a == 0.0 {
|
||||
discard;
|
||||
}
|
||||
return color * border.color;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! The world border wall (`WorldBorderRenderer`, `WorldBorderRenderState`):
|
||||
//! which border the camera is near, the wall's fade and status colour, the
|
||||
//! cached vertex buffer of the four walls, the scrolling texture transform
|
||||
//! and which walls are drawn. Kept free of GPU types so the parity fixture
|
||||
//! can compare every value with vanilla's renderer.
|
||||
use crate::worldborder::{WorldBorder, jclamp, jmax, jmin, status_color};
|
||||
|
||||
/// `WorldBorderRenderer.RENDERING_OFFSET`.
|
||||
const OFFSET: f64 = 0.01;
|
||||
|
||||
/// `WorldBorderRenderState`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct BorderRenderState {
|
||||
pub min_x: f64,
|
||||
pub max_x: f64,
|
||||
pub min_z: f64,
|
||||
pub max_z: f64,
|
||||
/// `BorderStatus.getColor`, set only when the wall is visible.
|
||||
pub tint: u32,
|
||||
pub alpha: f64,
|
||||
}
|
||||
impl BorderRenderState {
|
||||
/// `closestBorder`: the four walls by distance from (x, z), nearest
|
||||
/// first (a stable sort of north, south, west, east), as
|
||||
/// `Direction.get2DDataValue` side indices and distances.
|
||||
pub fn closest_border(&self, x: f64, z: f64) -> [(usize, f64); 4] {
|
||||
let mut sides = [
|
||||
(2, z - self.min_z),
|
||||
(0, self.max_z - z),
|
||||
(1, x - self.min_x),
|
||||
(3, self.max_x - x),
|
||||
];
|
||||
// Comparator.comparingDouble: Double.compare order, stable.
|
||||
sides.sort_by(|a, b| java_double_compare(a.1, b.1));
|
||||
sides
|
||||
}
|
||||
}
|
||||
/// `Double.compare`.
|
||||
fn java_double_compare(a: f64, b: f64) -> std::cmp::Ordering {
|
||||
use std::cmp::Ordering::*;
|
||||
if a < b {
|
||||
Less
|
||||
} else if a > b {
|
||||
Greater
|
||||
} else {
|
||||
let bits = |v: f64| {
|
||||
if v.is_nan() {
|
||||
0x7ff8_0000_0000_0000u64 as i64
|
||||
} else {
|
||||
v.to_bits() as i64
|
||||
}
|
||||
};
|
||||
bits(a).cmp(&bits(b))
|
||||
}
|
||||
}
|
||||
|
||||
/// `WorldBorderRenderer.extract`.
|
||||
pub fn extract(
|
||||
border: &WorldBorder,
|
||||
partial: f32,
|
||||
camera: [f64; 3],
|
||||
render_distance: f64,
|
||||
state: &mut BorderRenderState,
|
||||
) {
|
||||
let [min_x, min_z, max_x, max_z] = border.bounds_at(partial);
|
||||
state.min_x = min_x;
|
||||
state.max_x = max_x;
|
||||
state.min_z = min_z;
|
||||
state.max_z = max_z;
|
||||
let (x, z) = (camera[0], camera[2]);
|
||||
let deep_inside = x < max_x - render_distance
|
||||
&& x > min_x + render_distance
|
||||
&& z < max_z - render_distance
|
||||
&& z > min_z + render_distance;
|
||||
let far_outside = x < min_x - render_distance
|
||||
|| x > max_x + render_distance
|
||||
|| z < min_z - render_distance
|
||||
|| z > max_z + render_distance;
|
||||
if !deep_inside && !far_outside {
|
||||
let alpha = 1.0 - border.distance(x, z) / render_distance;
|
||||
state.alpha = jclamp(alpha.powf(4.0), 0.0, 1.0);
|
||||
state.tint = status_color(border.status());
|
||||
} else {
|
||||
state.alpha = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// One `POSITION_TEX` vertex of the wall buffer: position relative to the
|
||||
/// buffer origin and texture coordinates.
|
||||
pub type BorderVertex = [f32; 5];
|
||||
|
||||
/// What `WorldBorderRenderer.prepareDynamicTransforms` writes: the colour
|
||||
/// modulator, the model offset of the buffer origin from the camera and
|
||||
/// the texture matrix translation (both u and v).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BorderTransforms {
|
||||
pub color: [f32; 4],
|
||||
pub model_offset: [f32; 3],
|
||||
pub texture_offset: f32,
|
||||
}
|
||||
|
||||
/// `WorldBorderRenderer`'s cached wall buffer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BorderRenderer {
|
||||
needs_rebuild: bool,
|
||||
/// Set when `prepare` rebuilt the buffer (cleared by [`Self::take_rebuilt`]).
|
||||
rebuilt: bool,
|
||||
last_min_x: f64,
|
||||
last_min_z: f64,
|
||||
last_border: [f64; 4],
|
||||
/// Four quads: south, west, north, east (`get2DDataValue` order).
|
||||
pub vertices: [BorderVertex; 16],
|
||||
}
|
||||
impl Default for BorderRenderer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
needs_rebuild: true,
|
||||
rebuilt: false,
|
||||
last_min_x: 0.0,
|
||||
last_min_z: 0.0,
|
||||
last_border: [0.0; 4],
|
||||
vertices: [[0.0; 5]; 16],
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Mth.floor(double)`.
|
||||
fn floor(v: f64) -> i32 {
|
||||
let i = v as i32;
|
||||
if v < f64::from(i) { i - 1 } else { i }
|
||||
}
|
||||
/// `Mth.ceil(double)`.
|
||||
fn ceil(v: f64) -> i32 {
|
||||
let i = v as i32;
|
||||
if v > f64::from(i) { i + 1 } else { i }
|
||||
}
|
||||
/// `Mth.frac(double)`.
|
||||
fn frac(v: f64) -> f64 {
|
||||
v - v.floor()
|
||||
}
|
||||
impl BorderRenderer {
|
||||
/// `invalidate`: rebuild on the next prepare (e.g. after a resource reload).
|
||||
pub fn invalidate(&mut self) {
|
||||
self.needs_rebuild = true;
|
||||
}
|
||||
/// `prepare`: rebuilds the buffer when the border moved; returns whether
|
||||
/// the wall is drawn this frame.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
state: &BorderRenderState,
|
||||
camera: [f64; 3],
|
||||
render_distance: f64,
|
||||
depth_far: f32,
|
||||
) -> bool {
|
||||
if state.alpha <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let half_height = depth_far;
|
||||
let v0 = (-frac(camera[1] * 0.5)) as f32;
|
||||
let v1 = v0 + half_height;
|
||||
let border = [state.min_x, state.min_z, state.max_x, state.max_z];
|
||||
if self.needs_rebuild || border != self.last_border {
|
||||
self.rebuild(
|
||||
state,
|
||||
render_distance,
|
||||
camera[2],
|
||||
camera[0],
|
||||
half_height,
|
||||
v1,
|
||||
v0,
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `rebuildWorldBorderBuffer`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rebuild(
|
||||
&mut self,
|
||||
state: &BorderRenderState,
|
||||
render_distance: f64,
|
||||
camera_z: f64,
|
||||
camera_x: f64,
|
||||
h: f32,
|
||||
v1: f32,
|
||||
v0: f32,
|
||||
) {
|
||||
let border_min_x = state.min_x + OFFSET;
|
||||
let border_max_x = state.max_x - OFFSET;
|
||||
let border_min_z = state.min_z + OFFSET;
|
||||
let border_max_z = state.max_z - OFFSET;
|
||||
let min_z = jmax(f64::from(floor(camera_z - render_distance)), border_min_z);
|
||||
let max_z = jmin(f64::from(ceil(camera_z + render_distance)), border_max_z);
|
||||
let u0z = (floor(min_z) & 1) as f32 * 0.5;
|
||||
let u1z = (max_z - min_z) as f32 / 2.0;
|
||||
let min_x = jmax(f64::from(floor(camera_x - render_distance)), border_min_x);
|
||||
let max_x = jmin(f64::from(ceil(camera_x + render_distance)), border_max_x);
|
||||
let u0x = (floor(min_x) & 1) as f32 * 0.5;
|
||||
let u1x = (max_x - min_x) as f32 / 2.0;
|
||||
let (dx, dz) = ((max_x - min_x) as f32, (max_z - min_z) as f32);
|
||||
let south = (border_max_z - min_z) as f32;
|
||||
let east = (border_max_x - min_x) as f32;
|
||||
self.vertices = [
|
||||
[0.0, -h, south, u0x, v1],
|
||||
[dx, -h, south, u1x + u0x, v1],
|
||||
[dx, h, south, u1x + u0x, v0],
|
||||
[0.0, h, south, u0x, v0],
|
||||
[0.0, -h, 0.0, u0z, v1],
|
||||
[0.0, -h, dz, u1z + u0z, v1],
|
||||
[0.0, h, dz, u1z + u0z, v0],
|
||||
[0.0, h, 0.0, u0z, v0],
|
||||
[dx, -h, 0.0, u0x, v1],
|
||||
[0.0, -h, 0.0, u1x + u0x, v1],
|
||||
[0.0, h, 0.0, u1x + u0x, v0],
|
||||
[dx, h, 0.0, u0x, v0],
|
||||
[east, -h, dz, u0z, v1],
|
||||
[east, -h, 0.0, u1z + u0z, v1],
|
||||
[east, h, 0.0, u1z + u0z, v0],
|
||||
[east, h, dz, u0z, v0],
|
||||
];
|
||||
self.last_border = [state.min_x, state.min_z, state.max_x, state.max_z];
|
||||
self.last_min_x = min_x;
|
||||
self.last_min_z = min_z;
|
||||
self.needs_rebuild = false;
|
||||
self.rebuilt = true;
|
||||
}
|
||||
/// Whether the buffer was rebuilt since the last call (it must be
|
||||
/// uploaded again).
|
||||
pub fn take_rebuilt(&mut self) -> bool {
|
||||
std::mem::take(&mut self.rebuilt)
|
||||
}
|
||||
/// `prepareDynamicTransforms` at `Util.getMillis()` = `millis`.
|
||||
pub fn transforms(
|
||||
&self,
|
||||
state: &BorderRenderState,
|
||||
camera: [f64; 3],
|
||||
millis: i64,
|
||||
) -> BorderTransforms {
|
||||
let channel = |shift: u32| ((state.tint >> shift) & 0xFF) as f32 / 255.0;
|
||||
let offset = (millis % 3000) as f32 / 3000.0;
|
||||
BorderTransforms {
|
||||
color: [channel(16), channel(8), channel(0), state.alpha as f32],
|
||||
model_offset: [
|
||||
(self.last_min_x - camera[0]) as f32,
|
||||
(-camera[1]) as f32,
|
||||
(self.last_min_z - camera[2]) as f32,
|
||||
],
|
||||
texture_offset: offset,
|
||||
}
|
||||
}
|
||||
/// `draw`: the walls within the render distance, nearest first, as side
|
||||
/// indices into [`Self::vertices`] (four vertices each).
|
||||
pub fn sides(state: &BorderRenderState, camera: [f64; 3], render_distance: f64) -> Vec<usize> {
|
||||
state
|
||||
.closest_border(camera[0], camera[2])
|
||||
.into_iter()
|
||||
.filter(|&(_, d)| d < render_distance)
|
||||
.map(|(side, _)| side)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn near_wall_is_drawn_and_tinted() {
|
||||
let mut border = WorldBorder::default();
|
||||
border.set_size(100.0);
|
||||
let mut state = BorderRenderState::default();
|
||||
let camera = [40.0, 64.0, 0.0];
|
||||
extract(&border, 0.0, camera, 128.0, &mut state);
|
||||
assert!(state.alpha > 0.0);
|
||||
assert_eq!(state.tint, 0x20A0FF);
|
||||
let mut r = BorderRenderer::default();
|
||||
assert!(r.prepare(&state, camera, 128.0, 512.0));
|
||||
// East wall first (10 blocks away), then north and south, then west.
|
||||
assert_eq!(
|
||||
BorderRenderer::sides(&state, camera, 128.0),
|
||||
vec![3, 2, 0, 1]
|
||||
);
|
||||
let t = r.transforms(&state, camera, 4500);
|
||||
assert_eq!(t.texture_offset, 0.5);
|
||||
// Far inside a huge border, nothing is drawn.
|
||||
border.set_size(10_000.0);
|
||||
extract(&border, 0.0, camera, 128.0, &mut state);
|
||||
assert_eq!(state.alpha, 0.0);
|
||||
assert!(!r.prepare(&state, camera, 128.0, 512.0));
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,9 @@ impl BossBar {
|
||||
hover: Some(Box::new(Text::literal(self.id.clone()))),
|
||||
..Style::default()
|
||||
};
|
||||
bracketed(comp_text(&self.name).with_style(&style))
|
||||
// ComponentUtils.wrapInSquareBrackets(name).withStyle(...): the
|
||||
// brackets take the colour too.
|
||||
bracketed(comp_text(&self.name)).with_style(&style)
|
||||
}
|
||||
/// The filled fraction of the bar (`BossEvent.getProgress`).
|
||||
pub fn progress(&self) -> f32 {
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
//! The camera (`Camera.tick`, `tickFov`, `calculateFov`, `alignWithEntity`,
|
||||
//! `getMaxZoom`, `setRotation`, `move`), view and hurt bobbing
|
||||
//! (`GameRenderer.bobView`, `bobHurt`), the walk and bob history they read
|
||||
//! (`ClientAvatarState`), and `AbstractClientPlayer.getFieldOfViewModifier`.
|
||||
//!
|
||||
//! The float arithmetic follows vanilla and JOML's default (non-FMA,
|
||||
//! non-fast-math) code paths operation by operation.
|
||||
use horizon_core::geometry::trig;
|
||||
|
||||
/// `(float) Math.PI`.
|
||||
const PI: f32 = std::f32::consts::PI;
|
||||
/// `(float) (Math.PI / 180.0)`.
|
||||
const DEG_TO_RAD: f32 = trig::DEG_TO_RAD;
|
||||
|
||||
/// `Math.min(float, float)`: NaN wins and -0.0 is below 0.0.
|
||||
pub fn java_min(a: f32, b: f32) -> f32 {
|
||||
if a.is_nan() || b.is_nan() {
|
||||
f32::NAN
|
||||
} else if a == 0.0 && b == 0.0 {
|
||||
f32::from_bits(a.to_bits() | b.to_bits())
|
||||
} else if a <= b {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
/// `Math.max(float, float)`.
|
||||
pub fn java_max(a: f32, b: f32) -> f32 {
|
||||
if a.is_nan() || b.is_nan() {
|
||||
f32::NAN
|
||||
} else if a == 0.0 && b == 0.0 {
|
||||
f32::from_bits(a.to_bits() & b.to_bits())
|
||||
} else if a >= b {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
/// `Mth.lerp(float, float, float)`.
|
||||
pub fn lerp(alpha: f32, p0: f32, p1: f32) -> f32 {
|
||||
p0 + alpha * (p1 - p0)
|
||||
}
|
||||
/// `Mth.lerp(double, double, double)`.
|
||||
pub fn lerp_d(alpha: f64, p0: f64, p1: f64) -> f64 {
|
||||
p0 + alpha * (p1 - p0)
|
||||
}
|
||||
/// `Mth.wrapDegrees(float)`.
|
||||
pub fn wrap_degrees(angle: f32) -> f32 {
|
||||
let mut normalized = angle % 360.0;
|
||||
if normalized >= 180.0 {
|
||||
normalized -= 360.0;
|
||||
}
|
||||
if normalized < -180.0 {
|
||||
normalized += 360.0;
|
||||
}
|
||||
normalized
|
||||
}
|
||||
/// `Mth.clamp(float, float, float)`.
|
||||
pub fn clamp(value: f32, min: f32, max: f32) -> f32 {
|
||||
if value < min {
|
||||
min
|
||||
} else {
|
||||
java_min(value, max)
|
||||
}
|
||||
}
|
||||
|
||||
/// JOML's float trigonometry (`org.joml.Math` without fast math).
|
||||
pub mod joml {
|
||||
use super::PI;
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
const HALF_PI: f32 = std::f32::consts::FRAC_PI_2;
|
||||
|
||||
/// `Math.sin(float)`: `java.lang.Math.sin` narrowed.
|
||||
pub fn sin(rad: f32) -> f32 {
|
||||
f64::from(rad).sin() as f32
|
||||
}
|
||||
/// `Math.cosFromSin(float, float)`.
|
||||
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let a = angle + HALF_PI;
|
||||
let mut b = a - (a / TAU) as i32 as f32 * TAU;
|
||||
if f64::from(b) < 0.0 {
|
||||
b += TAU;
|
||||
}
|
||||
if b >= PI { -cos } else { cos }
|
||||
}
|
||||
|
||||
/// A JOML `Quaternionf` (x, y, z, w).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Quat {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: f32,
|
||||
pub w: f32,
|
||||
}
|
||||
impl Quat {
|
||||
pub const IDENTITY: Self = Self {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
w: 1.0,
|
||||
};
|
||||
/// `Quaternionf.rotationYXZ(angleY, angleX, angleZ)`.
|
||||
pub fn rotation_yxz(angle_y: f32, angle_x: f32, angle_z: f32) -> Self {
|
||||
let sx = sin(angle_x * 0.5);
|
||||
let cx = cos_from_sin(sx, angle_x * 0.5);
|
||||
let sy = sin(angle_y * 0.5);
|
||||
let cy = cos_from_sin(sy, angle_y * 0.5);
|
||||
let sz = sin(angle_z * 0.5);
|
||||
let cz = cos_from_sin(sz, angle_z * 0.5);
|
||||
let x = cy * sx;
|
||||
let y = sy * cx;
|
||||
let z = sy * sx;
|
||||
let w = cy * cx;
|
||||
Self {
|
||||
x: x * cz + y * sz,
|
||||
y: y * cz - x * sz,
|
||||
z: w * sz - z * cz,
|
||||
w: w * cz + z * sz,
|
||||
}
|
||||
}
|
||||
/// `Quaternionf.transform(x, y, z, dest)` (`Vector3f.rotate`).
|
||||
pub fn transform(&self, v: [f32; 3]) -> [f32; 3] {
|
||||
let [x, y, z] = v;
|
||||
let xx = self.x * self.x;
|
||||
let yy = self.y * self.y;
|
||||
let zz = self.z * self.z;
|
||||
let ww = self.w * self.w;
|
||||
let xy = self.x * self.y;
|
||||
let xz = self.x * self.z;
|
||||
let yz = self.y * self.z;
|
||||
let xw = self.x * self.w;
|
||||
let zw = self.z * self.w;
|
||||
let yw = self.y * self.w;
|
||||
let k = 1.0 / (xx + yy + zz + ww);
|
||||
[
|
||||
(xx - yy - zz + ww) * k * x + (2.0 * (xy - zw) * k * y + 2.0 * (xz + yw) * k * z),
|
||||
2.0 * (xy + zw) * k * x + ((yy - xx - zz + ww) * k * y + 2.0 * (yz - xw) * k * z),
|
||||
2.0 * (xz - yw) * k * x + (2.0 * (yz + xw) * k * y + (zz - xx - yy + ww) * k * z),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const PERSPECTIVE: u8 = 1;
|
||||
const IDENTITY: u8 = 4;
|
||||
const TRANSLATION: u8 = 8;
|
||||
|
||||
/// A JOML `Matrix4f` with its property flags, which select the code
|
||||
/// paths (and so the rounding) of later operations. `m[column * 4 + row]`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Mat4 {
|
||||
pub m: [f32; 16],
|
||||
pub properties: u8,
|
||||
}
|
||||
impl Default for Mat4 {
|
||||
fn default() -> Self {
|
||||
Self::IDENTITY
|
||||
}
|
||||
}
|
||||
impl Mat4 {
|
||||
/// `new Matrix4f()`.
|
||||
pub const IDENTITY: Self = Self {
|
||||
m: [
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||
],
|
||||
properties: 30,
|
||||
};
|
||||
fn get(&self, column: usize, row: usize) -> f32 {
|
||||
self.m[column * 4 + row]
|
||||
}
|
||||
fn set(&mut self, column: usize, row: usize, v: f32) {
|
||||
self.m[column * 4 + row] = v;
|
||||
}
|
||||
fn reset_unless_identity(&mut self) {
|
||||
if self.properties & IDENTITY == 0 {
|
||||
self.m = Self::IDENTITY.m;
|
||||
}
|
||||
}
|
||||
/// `Matrix4f.translation(x, y, z)`.
|
||||
pub fn translation(&mut self, x: f32, y: f32, z: f32) {
|
||||
self.reset_unless_identity();
|
||||
self.set(3, 0, x);
|
||||
self.set(3, 1, y);
|
||||
self.set(3, 2, z);
|
||||
self.properties = 26;
|
||||
}
|
||||
/// `Matrix4f.setTranslation(x, y, z)`.
|
||||
pub fn set_translation(&mut self, x: f32, y: f32, z: f32) {
|
||||
self.set(3, 0, x);
|
||||
self.set(3, 1, y);
|
||||
self.set(3, 2, z);
|
||||
self.properties &= !(PERSPECTIVE | IDENTITY);
|
||||
}
|
||||
/// `Matrix4f.translate(x, y, z)`.
|
||||
pub fn translate(&mut self, x: f32, y: f32, z: f32) {
|
||||
if self.properties & IDENTITY != 0 {
|
||||
self.translation(x, y, z);
|
||||
return;
|
||||
}
|
||||
for row in 0..4 {
|
||||
let v = self.get(0, row) * x
|
||||
+ (self.get(1, row) * y + (self.get(2, row) * z + self.get(3, row)));
|
||||
self.set(3, row, v);
|
||||
}
|
||||
self.properties &= !(PERSPECTIVE | IDENTITY);
|
||||
}
|
||||
fn rotation_axis(&mut self, axis: usize, ang: f32) {
|
||||
let sin = sin(ang);
|
||||
let cos = cos_from_sin(sin, ang);
|
||||
self.reset_unless_identity();
|
||||
match axis {
|
||||
0 => {
|
||||
self.set(1, 1, cos);
|
||||
self.set(1, 2, sin);
|
||||
self.set(2, 1, -sin);
|
||||
self.set(2, 2, cos);
|
||||
}
|
||||
1 => {
|
||||
self.set(0, 0, cos);
|
||||
self.set(0, 2, -sin);
|
||||
self.set(2, 0, sin);
|
||||
self.set(2, 2, cos);
|
||||
}
|
||||
_ => {
|
||||
self.set(0, 0, cos);
|
||||
self.set(0, 1, sin);
|
||||
self.set(1, 0, -sin);
|
||||
self.set(1, 1, cos);
|
||||
}
|
||||
}
|
||||
self.properties = 18;
|
||||
}
|
||||
/// `Matrix4f.rotateX/rotateY/rotateZ(ang)`, with the identity and
|
||||
/// pure-translation shortcuts.
|
||||
fn rotate_axis(&mut self, axis: usize, ang: f32) {
|
||||
if self.properties & IDENTITY != 0 {
|
||||
self.rotation_axis(axis, ang);
|
||||
return;
|
||||
}
|
||||
if self.properties & TRANSLATION != 0 {
|
||||
let (x, y, z) = (self.get(3, 0), self.get(3, 1), self.get(3, 2));
|
||||
self.rotation_axis(axis, ang);
|
||||
self.set_translation(x, y, z);
|
||||
return;
|
||||
}
|
||||
let sin = sin(ang);
|
||||
let cos = cos_from_sin(sin, ang);
|
||||
let s = *self;
|
||||
match axis {
|
||||
0 => {
|
||||
for row in 0..4 {
|
||||
let (l1, l2) = (s.get(1, row), s.get(2, row));
|
||||
self.set(2, row, l1 * -sin + l2 * cos);
|
||||
self.set(1, row, l1 * cos + l2 * sin);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
for row in 0..4 {
|
||||
let (l0, l2) = (s.get(0, row), s.get(2, row));
|
||||
self.set(0, row, l0 * cos + l2 * -sin);
|
||||
self.set(2, row, l0 * sin + l2 * cos);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// rotateTowardsXY(sin, cos).
|
||||
let (dx, dy) = (sin, cos);
|
||||
for row in 0..4 {
|
||||
let (l0, l1) = (s.get(0, row), s.get(1, row));
|
||||
self.set(0, row, l0 * dy + l1 * dx);
|
||||
self.set(1, row, l0 * -dx + l1 * dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.properties &= !(PERSPECTIVE | IDENTITY | TRANSLATION);
|
||||
}
|
||||
pub fn rotate_x(&mut self, ang: f32) {
|
||||
self.rotate_axis(0, ang);
|
||||
}
|
||||
pub fn rotate_y(&mut self, ang: f32) {
|
||||
self.rotate_axis(1, ang);
|
||||
}
|
||||
pub fn rotate_z(&mut self, ang: f32) {
|
||||
// rotateTowardsXY also short-cuts on identity, but rotateZ has
|
||||
// already taken that branch.
|
||||
self.rotate_axis(2, ang);
|
||||
}
|
||||
}
|
||||
}
|
||||
use joml::{Mat4, Quat};
|
||||
|
||||
/// `CameraType`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CameraType {
|
||||
#[default]
|
||||
FirstPerson,
|
||||
ThirdPersonBack,
|
||||
ThirdPersonFront,
|
||||
}
|
||||
impl CameraType {
|
||||
pub fn is_first_person(self) -> bool {
|
||||
self == Self::FirstPerson
|
||||
}
|
||||
pub fn is_mirrored(self) -> bool {
|
||||
self == Self::ThirdPersonFront
|
||||
}
|
||||
/// `CameraType.cycle` (F5).
|
||||
pub fn cycle(self) -> Self {
|
||||
match self {
|
||||
Self::FirstPerson => Self::ThirdPersonBack,
|
||||
Self::ThirdPersonBack => Self::ThirdPersonFront,
|
||||
Self::ThirdPersonFront => Self::FirstPerson,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FovPlayer {
|
||||
/// `Abilities.flying`.
|
||||
pub flying: bool,
|
||||
/// `Abilities.getWalkingSpeed()`.
|
||||
pub walking_speed: f32,
|
||||
/// `getAttributeValue(MOVEMENT_SPEED)`.
|
||||
pub movement_speed: f64,
|
||||
/// The item in use and its use ticks, when using one.
|
||||
pub using: Option<(UseItem, i32)>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UseItem {
|
||||
Bow,
|
||||
Spyglass,
|
||||
Other,
|
||||
}
|
||||
/// `AbstractClientPlayer.getFieldOfViewModifier(firstPerson, effectScale)`.
|
||||
pub fn field_of_view_modifier(p: &FovPlayer, first_person: bool, effect_scale: f32) -> f32 {
|
||||
let mut modifier = 1.0f32;
|
||||
if p.flying {
|
||||
modifier *= 1.1;
|
||||
}
|
||||
let walking_speed = p.walking_speed;
|
||||
if walking_speed != 0.0 {
|
||||
let speed_factor = p.movement_speed as f32 / walking_speed;
|
||||
modifier *= (speed_factor + 1.0) / 2.0;
|
||||
}
|
||||
if let Some((item, ticks)) = p.using {
|
||||
match item {
|
||||
UseItem::Bow => {
|
||||
let scale = java_min(ticks as f32 / 20.0, 1.0);
|
||||
modifier *= 1.0 - scale * scale * 0.15;
|
||||
}
|
||||
// isScoping: using a spyglass.
|
||||
UseItem::Spyglass if first_person => return 0.1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
lerp(effect_scale, 1.0, modifier)
|
||||
}
|
||||
|
||||
/// The camera entity as `Camera.alignWithEntity` and the field-of-view
|
||||
/// death and hurt effects see it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CameraEntity {
|
||||
pub position: [f64; 3],
|
||||
/// `xo`, `yo`, `zo`.
|
||||
pub previous: [f64; 3],
|
||||
pub x_rot: f32,
|
||||
pub y_rot: f32,
|
||||
pub x_rot_o: f32,
|
||||
pub y_rot_o: f32,
|
||||
/// `Entity.getEyeHeight()`.
|
||||
pub eye_height: f32,
|
||||
/// A living entity's `getScale()` and camera distance attribute.
|
||||
pub living: Option<LivingCamera>,
|
||||
/// The vehicle's scale and camera distance when it is living.
|
||||
pub mount: Option<(f32, f64)>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LivingCamera {
|
||||
pub scale: f32,
|
||||
pub camera_distance: f64,
|
||||
/// `isSleeping()` with `getBedOrientation()` as its `toYRot()`.
|
||||
pub sleeping: Option<Option<f32>>,
|
||||
/// `isDeadOrDying()` and `deathTime`.
|
||||
pub dead: Option<i32>,
|
||||
}
|
||||
impl CameraEntity {
|
||||
/// `Entity.getXRot(partialTicks)`.
|
||||
pub fn view_x_rot(&self, partial: f32) -> f32 {
|
||||
if partial == 1.0 {
|
||||
self.x_rot
|
||||
} else {
|
||||
lerp(partial, self.x_rot_o, self.x_rot)
|
||||
}
|
||||
}
|
||||
/// `LocalPlayer.getViewYRot` (`getYRot(partialTicks)`, a `Mth.rotLerp`).
|
||||
pub fn view_y_rot(&self, partial: f32) -> f32 {
|
||||
if partial == 1.0 {
|
||||
self.y_rot
|
||||
} else {
|
||||
self.y_rot_o + partial * wrap_degrees(self.y_rot - self.y_rot_o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fluid `Camera.getFluidInCamera` reports, as the field of view sees it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CameraFluid {
|
||||
#[default]
|
||||
None,
|
||||
Water,
|
||||
Lava,
|
||||
PowderSnow,
|
||||
}
|
||||
|
||||
/// `Camera`: the eye-height and field-of-view easing and the view placement.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Camera {
|
||||
pub eye_height: f32,
|
||||
pub eye_height_old: f32,
|
||||
pub fov_modifier: f32,
|
||||
pub old_fov_modifier: f32,
|
||||
pub position: [f64; 3],
|
||||
pub x_rot: f32,
|
||||
pub y_rot: f32,
|
||||
pub rotation: Quat,
|
||||
pub forwards: [f32; 3],
|
||||
pub up: [f32; 3],
|
||||
pub left: [f32; 3],
|
||||
pub detached: bool,
|
||||
}
|
||||
impl Default for Camera {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
eye_height: 0.0,
|
||||
eye_height_old: 0.0,
|
||||
fov_modifier: 0.0,
|
||||
old_fov_modifier: 0.0,
|
||||
position: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
rotation: Quat::IDENTITY,
|
||||
forwards: [0.0, 0.0, -1.0],
|
||||
up: [0.0, 1.0, 0.0],
|
||||
left: [-1.0, 0.0, 0.0],
|
||||
detached: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Camera {
|
||||
/// `Camera.tick`: the eye height eases halfway toward the entity's
|
||||
/// (with a level and an entity), then `tickFov` with the camera entity's
|
||||
/// field-of-view modifier target (1 when it is not a player).
|
||||
pub fn tick(&mut self, entity_eye_height: Option<f32>, target_fov_modifier: f32) {
|
||||
if let Some(eye) = entity_eye_height {
|
||||
self.eye_height_old = self.eye_height;
|
||||
self.eye_height += (eye - self.eye_height) * 0.5;
|
||||
}
|
||||
self.old_fov_modifier = self.fov_modifier;
|
||||
self.fov_modifier += (target_fov_modifier - self.fov_modifier) * 0.5;
|
||||
self.fov_modifier = clamp(self.fov_modifier, 0.1, 1.5);
|
||||
}
|
||||
/// `Camera.setRotation`.
|
||||
pub fn set_rotation(&mut self, y_rot: f32, x_rot: f32) {
|
||||
self.x_rot = x_rot;
|
||||
self.y_rot = y_rot;
|
||||
self.rotation = Quat::rotation_yxz(PI - y_rot * DEG_TO_RAD, -x_rot * DEG_TO_RAD, 0.0);
|
||||
self.forwards = self.rotation.transform([0.0, 0.0, -1.0]);
|
||||
self.up = self.rotation.transform([0.0, 1.0, 0.0]);
|
||||
self.left = self.rotation.transform([-1.0, 0.0, 0.0]);
|
||||
}
|
||||
/// `Camera.move(forwards, up, right)`.
|
||||
pub fn move_by(&mut self, forwards: f32, up: f32, right: f32) {
|
||||
let offset = self.rotation.transform([right, up, -forwards]);
|
||||
self.position = [
|
||||
self.position[0] + f64::from(offset[0]),
|
||||
self.position[1] + f64::from(offset[1]),
|
||||
self.position[2] + f64::from(offset[2]),
|
||||
];
|
||||
}
|
||||
/// `Camera.alignWithEntity` (not riding a minecart with the new
|
||||
/// movement): the entity's view rotation and interpolated eye, then a
|
||||
/// detached camera backed off along the view (`getMaxZoom`) or a sleeping
|
||||
/// one turned to the bed. `clip` is `Level.clip` with
|
||||
/// `ClipContext.Block.VISUAL` and no fluids; it returns the hit location
|
||||
/// unless the ray misses.
|
||||
pub fn align_with_entity(
|
||||
&mut self,
|
||||
entity: &CameraEntity,
|
||||
partial: f32,
|
||||
camera_type: CameraType,
|
||||
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) {
|
||||
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
|
||||
let p = f64::from(partial);
|
||||
self.position = [
|
||||
lerp_d(p, entity.previous[0], entity.position[0]),
|
||||
lerp_d(p, entity.previous[1], entity.position[1])
|
||||
+ f64::from(lerp(partial, self.eye_height_old, self.eye_height)),
|
||||
lerp_d(p, entity.previous[2], entity.position[2]),
|
||||
];
|
||||
self.detached = !camera_type.is_first_person();
|
||||
if self.detached {
|
||||
if camera_type.is_mirrored() {
|
||||
self.set_rotation(self.y_rot + 180.0, -self.x_rot);
|
||||
}
|
||||
let (mut distance, mut scale) = (4.0f32, 1.0f32);
|
||||
if let Some(living) = &entity.living {
|
||||
scale = living.scale;
|
||||
distance = living.camera_distance as f32;
|
||||
}
|
||||
let (mut mount_scale, mut mount_distance) = (scale, distance);
|
||||
if let Some((s, d)) = entity.mount {
|
||||
mount_scale = s;
|
||||
mount_distance = d as f32;
|
||||
}
|
||||
let zoom = self.max_zoom(
|
||||
java_max(scale * distance, mount_scale * mount_distance),
|
||||
&mut clip,
|
||||
);
|
||||
self.move_by(-zoom, 0.0, 0.0);
|
||||
} else if let Some(LivingCamera {
|
||||
sleeping: Some(bed),
|
||||
..
|
||||
}) = entity.living
|
||||
{
|
||||
self.set_rotation(bed.map_or(0.0, |yaw| yaw - 180.0), 0.0);
|
||||
self.move_by(0.0, 0.15, 0.0);
|
||||
}
|
||||
}
|
||||
/// `Camera.getMaxZoom`: eight rays from the corners of a 0.2 cube about
|
||||
/// the eye, shortening the distance to the nearest visual-shape hit.
|
||||
pub fn max_zoom(
|
||||
&self,
|
||||
mut distance: f32,
|
||||
clip: &mut impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) -> f32 {
|
||||
let at = self.position;
|
||||
for i in 0..8 {
|
||||
let offset = [
|
||||
((i & 1) * 2 - 1) as f32,
|
||||
((i >> 1 & 1) * 2 - 1) as f32,
|
||||
((i >> 2 & 1) * 2 - 1) as f32,
|
||||
];
|
||||
let from: [f64; 3] = std::array::from_fn(|a| at[a] + f64::from(offset[a] * 0.1));
|
||||
let back = f64::from(-distance);
|
||||
let to: [f64; 3] =
|
||||
std::array::from_fn(|a| from[a] + f64::from(self.forwards[a]) * back);
|
||||
if let Some(hit) = clip(from, to) {
|
||||
let d = [hit[0] - at[0], hit[1] - at[1], hit[2] - at[2]];
|
||||
let dist_sq = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]) as f32;
|
||||
if dist_sq < distance * distance {
|
||||
distance = dist_sq.sqrt();
|
||||
}
|
||||
}
|
||||
}
|
||||
distance
|
||||
}
|
||||
/// `Camera.calculateFov`: the option scaled by the eased modifier, then
|
||||
/// the death and fluid effects.
|
||||
pub fn calculate_fov(
|
||||
&self,
|
||||
partial: f32,
|
||||
fov_option: i32,
|
||||
effect_scale: f32,
|
||||
dead: Option<i32>,
|
||||
fluid: CameraFluid,
|
||||
panoramic: bool,
|
||||
) -> f32 {
|
||||
if panoramic {
|
||||
return 90.0;
|
||||
}
|
||||
let fov = fov_option as f32 * lerp(partial, self.old_fov_modifier, self.fov_modifier);
|
||||
modify_fov_based_on_death_or_fluid(partial, fov, effect_scale, dead, fluid)
|
||||
}
|
||||
/// `Camera.calculateHudFov`.
|
||||
pub fn calculate_hud_fov(
|
||||
&self,
|
||||
partial: f32,
|
||||
effect_scale: f32,
|
||||
dead: Option<i32>,
|
||||
fluid: CameraFluid,
|
||||
) -> f32 {
|
||||
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
|
||||
}
|
||||
}
|
||||
/// `Camera.modifyFovBasedOnDeathOrFluid`. `dead` is the camera entity's
|
||||
/// `deathTime` while it is a living entity that is dead or dying.
|
||||
pub fn modify_fov_based_on_death_or_fluid(
|
||||
partial: f32,
|
||||
mut fov: f32,
|
||||
effect_scale: f32,
|
||||
dead: Option<i32>,
|
||||
fluid: CameraFluid,
|
||||
) -> f32 {
|
||||
if let Some(death_time) = dead {
|
||||
let duration = java_min(death_time as f32 + partial, 20.0);
|
||||
fov /= (1.0 - 500.0 / (duration + 500.0)) * 2.0 + 1.0;
|
||||
}
|
||||
if matches!(fluid, CameraFluid::Lava | CameraFluid::Water) {
|
||||
fov *= lerp(effect_scale, 1.0, 0.857_142_87);
|
||||
}
|
||||
fov
|
||||
}
|
||||
|
||||
/// `ClientAvatarState`: the walked distance and view bob of a player.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct AvatarBob {
|
||||
pub walk_dist: f32,
|
||||
pub walk_dist_o: f32,
|
||||
pub bob: f32,
|
||||
pub bob_o: f32,
|
||||
}
|
||||
impl AvatarBob {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
|
||||
pub fn tick(&mut self) {
|
||||
self.walk_dist_o = self.walk_dist;
|
||||
}
|
||||
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
|
||||
/// with the float horizontal displacement.
|
||||
pub fn add_walked(&mut self, dx: f32, dz: f32) {
|
||||
self.walk_dist += (dx * dx + dz * dz).sqrt() * 0.6;
|
||||
}
|
||||
/// `AbstractClientPlayer.updateBob`: toward the horizontal speed (at most
|
||||
/// 0.1) on the ground, toward 0 otherwise.
|
||||
pub fn update_bob(&mut self, on_ground_alive_not_swimming: bool, velocity: [f64; 3]) {
|
||||
let target = if on_ground_alive_not_swimming {
|
||||
java_min(
|
||||
0.1,
|
||||
(velocity[0] * velocity[0] + velocity[2] * velocity[2]).sqrt() as f32,
|
||||
)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.bob_o = self.bob;
|
||||
self.bob += (target - self.bob) * 0.4;
|
||||
}
|
||||
/// `ClientAvatarState.resetBob` (`AbstractClientPlayer.rideTick`).
|
||||
pub fn reset_bob(&mut self) {
|
||||
self.bob_o = self.bob;
|
||||
self.bob = 0.0;
|
||||
}
|
||||
pub fn interpolated_bob(&self, partial: f32) -> f32 {
|
||||
lerp(partial, self.bob_o, self.bob)
|
||||
}
|
||||
pub fn backwards_interpolated_walk_distance(&self, partial: f32) -> f32 {
|
||||
let wda = self.walk_dist - self.walk_dist_o;
|
||||
-(self.walk_dist + wda * partial)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of `CameraRenderState.entityRenderState` the bobbing reads.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct BobState {
|
||||
/// A living camera entity: `hurtTime - partial`, `hurtDuration`,
|
||||
/// `getHurtDir()`, and `deathTime + partial` when dead or dying.
|
||||
pub living: Option<HurtBob>,
|
||||
/// A player camera entity: the backwards-interpolated walk distance and
|
||||
/// the interpolated bob.
|
||||
pub player: Option<(f32, f32)>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct HurtBob {
|
||||
pub hurt_time: f32,
|
||||
pub hurt_duration: i32,
|
||||
pub hurt_dir: f32,
|
||||
pub death_time: Option<f32>,
|
||||
}
|
||||
/// `PoseStack.rotateDegrees` on the pose with an `Axis`.
|
||||
fn rotate_degrees(m: &mut Mat4, axis: usize, degrees: f32) {
|
||||
let ang = degrees * DEG_TO_RAD;
|
||||
match axis {
|
||||
0 => m.rotate_x(ang),
|
||||
1 => m.rotate_y(ang),
|
||||
_ => m.rotate_z(ang),
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.bobHurt`.
|
||||
pub fn bob_hurt(state: &BobState, damage_tilt_strength: f64, pose: &mut Mat4) {
|
||||
let Some(living) = state.living else { return };
|
||||
let mut hurt = living.hurt_time;
|
||||
if let Some(death_time) = living.death_time {
|
||||
let duration = java_min(death_time, 20.0);
|
||||
rotate_degrees(pose, 2, 40.0 - 8000.0 / (duration + 200.0));
|
||||
}
|
||||
if hurt < 0.0 {
|
||||
return;
|
||||
}
|
||||
hurt /= living.hurt_duration as f32;
|
||||
hurt = trig::sin(f64::from(hurt * hurt * hurt * hurt * PI));
|
||||
let rr = living.hurt_dir;
|
||||
rotate_degrees(pose, 1, -rr);
|
||||
let tilt = (f64::from(-hurt) * 14.0 * damage_tilt_strength) as f32;
|
||||
rotate_degrees(pose, 2, tilt);
|
||||
rotate_degrees(pose, 1, rr);
|
||||
}
|
||||
/// `GameRenderer.bobView`.
|
||||
pub fn bob_view(state: &BobState, pose: &mut Mat4) {
|
||||
let Some((walk, bob)) = state.player else {
|
||||
return;
|
||||
};
|
||||
pose.translate(
|
||||
trig::sin(f64::from(walk * PI)) * bob * 0.5,
|
||||
-(trig::cos(f64::from(walk * PI)) * bob).abs(),
|
||||
0.0,
|
||||
);
|
||||
rotate_degrees(pose, 2, trig::sin(f64::from(walk * PI)) * bob * 3.0);
|
||||
rotate_degrees(
|
||||
pose,
|
||||
0,
|
||||
(trig::cos(f64::from(walk * PI - 0.2)) * bob).abs() * 5.0,
|
||||
);
|
||||
}
|
||||
/// The bob matrix `GameRenderer.renderLevel` multiplies into the projection
|
||||
/// (and `renderItemInHand` into the hand's pose).
|
||||
pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option: bool) -> Mat4 {
|
||||
let mut pose = Mat4::IDENTITY;
|
||||
bob_hurt(state, damage_tilt_strength, &mut pose);
|
||||
if bob_view_option {
|
||||
bob_view(state, &mut pose);
|
||||
}
|
||||
pose
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn camera_eases_eye_height_and_fov() {
|
||||
let mut camera = Camera::default();
|
||||
camera.tick(Some(1.62), 1.0);
|
||||
assert_eq!(camera.eye_height, 0.81);
|
||||
assert_eq!(camera.fov_modifier, 0.5);
|
||||
camera.tick(None, 1.0);
|
||||
assert_eq!(camera.eye_height, 0.81);
|
||||
assert_eq!(camera.fov_modifier, 0.75);
|
||||
}
|
||||
#[test]
|
||||
fn rotation_faces_south_at_zero_yaw() {
|
||||
let mut camera = Camera::default();
|
||||
camera.set_rotation(0.0, 0.0);
|
||||
assert!((camera.forwards[2] - 1.0).abs() < 1e-6);
|
||||
camera.set_rotation(90.0, 0.0);
|
||||
assert!((camera.forwards[0] + 1.0).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn detached_camera_backs_off_until_blocked() {
|
||||
let entity = CameraEntity {
|
||||
position: [0.0; 3],
|
||||
previous: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
x_rot_o: 0.0,
|
||||
y_rot_o: 0.0,
|
||||
eye_height: 1.62,
|
||||
living: None,
|
||||
mount: None,
|
||||
};
|
||||
let mut camera = Camera::default();
|
||||
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| None);
|
||||
assert!((camera.position[2] + 4.0).abs() < 1e-6);
|
||||
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| {
|
||||
Some([0.0, 0.0, -1.0])
|
||||
});
|
||||
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
|
||||
}
|
||||
#[test]
|
||||
fn fov_modifier_combines_speed_flight_and_bow() {
|
||||
let mut p = FovPlayer {
|
||||
flying: false,
|
||||
walking_speed: 0.1,
|
||||
movement_speed: 0.13,
|
||||
using: None,
|
||||
};
|
||||
assert_eq!(
|
||||
field_of_view_modifier(&p, true, 1.0),
|
||||
(0.13f32 / 0.1 + 1.0) / 2.0
|
||||
);
|
||||
assert_eq!(field_of_view_modifier(&p, true, 0.0), 1.0);
|
||||
p.using = Some((UseItem::Spyglass, 3));
|
||||
assert_eq!(field_of_view_modifier(&p, true, 1.0), 0.1);
|
||||
assert_ne!(field_of_view_modifier(&p, false, 1.0), 0.1);
|
||||
}
|
||||
}
|
||||
+124
-110
@@ -5,6 +5,7 @@
|
||||
use crate::{
|
||||
command::{Dispatcher, ParseResults, Registries, Suggestion},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
hud_layout::{self, GuiDraw, PoseOp},
|
||||
text::{self, Style, Text},
|
||||
ui::{EditBox, char_len},
|
||||
};
|
||||
@@ -12,15 +13,16 @@ use crate::{
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
|
||||
/// The chat options (doubles, as vanilla keeps them).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChatOptions {
|
||||
pub opacity: f32,
|
||||
pub background_opacity: f32,
|
||||
pub scale: f32,
|
||||
pub width: f32,
|
||||
pub height_focused: f32,
|
||||
pub height_unfocused: f32,
|
||||
pub line_spacing: f32,
|
||||
pub opacity: f64,
|
||||
pub background_opacity: f64,
|
||||
pub scale: f64,
|
||||
pub width: f64,
|
||||
pub height_focused: f64,
|
||||
pub height_unfocused: f64,
|
||||
pub line_spacing: f64,
|
||||
}
|
||||
impl Default for ChatOptions {
|
||||
fn default() -> Self {
|
||||
@@ -37,20 +39,21 @@ impl Default for ChatOptions {
|
||||
}
|
||||
impl ChatOptions {
|
||||
/// ChatComponent.getWidth/getHeight.
|
||||
pub fn width_px(&self) -> f32 {
|
||||
(self.width * 280.0 + 40.0).floor()
|
||||
pub fn width_px(&self) -> i32 {
|
||||
(self.width * 280.0 + 40.0).floor() as i32
|
||||
}
|
||||
pub fn height_px(&self, focused: bool) -> f32 {
|
||||
pub fn height_px(&self, focused: bool) -> i32 {
|
||||
((if focused {
|
||||
self.height_focused
|
||||
} else {
|
||||
self.height_unfocused
|
||||
}) * 160.0
|
||||
+ 20.0)
|
||||
.floor()
|
||||
.floor() as i32
|
||||
}
|
||||
pub fn line_height(&self) -> f32 {
|
||||
(9.0 * (self.line_spacing + 1.0)).floor()
|
||||
/// `getLineHeight`.
|
||||
pub fn line_height(&self) -> i32 {
|
||||
(9.0 * (self.line_spacing + 1.0)) as i32
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -59,12 +62,25 @@ struct Message {
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
}
|
||||
/// A wrapped line (`GuiMessage.Line`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Line {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
}
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
/// fading out quadratically over 20.
|
||||
fn time_alpha(ticks: u64, added: u64) -> f32 {
|
||||
let delta = ticks as i64 - added as i64;
|
||||
let mut t = delta as f64 / 200.0;
|
||||
t = 1.0 - t;
|
||||
t *= 10.0;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
t *= t;
|
||||
t as f32
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Chat {
|
||||
messages: Vec<Message>,
|
||||
@@ -80,10 +96,11 @@ pub struct Chat {
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
(self.options.height_px(focused) / self.options.line_height().max(1.0)) as usize
|
||||
(self.options.height_px(focused) / self.options.line_height().max(1)) as usize
|
||||
}
|
||||
fn max_width(&self) -> f32 {
|
||||
(self.options.width_px() / self.options.scale.max(0.01)).floor()
|
||||
/// The wrapping width, `Mth.floor(getWidth() / getScale())`.
|
||||
fn max_width(&self) -> i32 {
|
||||
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
|
||||
}
|
||||
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
|
||||
pub fn add(
|
||||
@@ -108,7 +125,7 @@ impl Chat {
|
||||
}
|
||||
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
for line in atlas.font.split(&message.content, width) {
|
||||
for line in atlas.font.wrap_components(&message.content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
self.new_since_scroll = true;
|
||||
self.scroll_by(1, focused);
|
||||
@@ -164,116 +181,113 @@ impl Chat {
|
||||
self.scroll = pos as usize;
|
||||
}
|
||||
}
|
||||
/// ChatComponent.extractRenderState.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
if self.lines.is_empty() {
|
||||
return;
|
||||
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
|
||||
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
|
||||
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
|
||||
let total = self.lines.len();
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let o = &self.options;
|
||||
let scale = o.scale;
|
||||
if scale <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let max_width = (o.width_px() / scale).ceil();
|
||||
let chat_bottom = ((batch.height - 40.0) / scale).floor();
|
||||
let text_opacity = o.opacity * 0.9 + 0.1;
|
||||
let scale = o.scale as f32;
|
||||
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
|
||||
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let text_opacity = o.opacity as f32 * 0.9 + 0.1;
|
||||
let background_opacity = o.background_opacity as f32;
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text = (8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing).round();
|
||||
let per_page = self.lines_per_page(focused);
|
||||
let alpha_of = |line: &Line| -> f32 {
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
let alpha_of = |line: &Line| {
|
||||
if focused {
|
||||
return 1.0;
|
||||
1.0
|
||||
} else {
|
||||
time_alpha(ticks, line.added)
|
||||
}
|
||||
let t =
|
||||
((1.0 - (ticks.saturating_sub(line.added)) as f64 / 200.0) * 10.0).clamp(0.0, 1.0);
|
||||
(t * t) as f32
|
||||
};
|
||||
let visible = self.lines.len().saturating_sub(self.scroll).min(per_page);
|
||||
let mut count = 0;
|
||||
// Pass 1: backgrounds; pass 2: text (same order as vanilla).
|
||||
let s = |v: f32| v * scale;
|
||||
for i in (0..visible).rev() {
|
||||
let line = &self.lines[i + self.scroll];
|
||||
let alpha = alpha_of(line);
|
||||
if alpha <= 1.0e-5 {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let bottom = chat_bottom - i as f32 * entry_height;
|
||||
let top = bottom - entry_height;
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
s(top),
|
||||
s(max_width + 8.0),
|
||||
s(entry_height),
|
||||
rgba(0, 0, 0, (alpha * o.background_opacity * 255.0) as u8),
|
||||
);
|
||||
let visible = total
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(focused));
|
||||
let lines: Vec<(usize, &Line, f32)> = (0..visible)
|
||||
.rev()
|
||||
.map(|i| (i, &self.lines[i + self.scroll]))
|
||||
.map(|(i, line)| (i, line, alpha_of(line)))
|
||||
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
|
||||
.collect();
|
||||
let mut out = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
for &(i, _, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
max_width + 4 + 4,
|
||||
bottom,
|
||||
hud_layout::as8(alpha * background_opacity) << 24,
|
||||
));
|
||||
}
|
||||
for i in (0..visible).rev() {
|
||||
let line = &self.lines[i + self.scroll];
|
||||
let alpha = alpha_of(line);
|
||||
if alpha <= 1.0e-5 {
|
||||
continue;
|
||||
for &(i, line, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
let opacity = alpha * text_opacity;
|
||||
let color = hud_layout::white(opacity);
|
||||
if color >> 24 != 0 {
|
||||
out.push(GuiDraw::Text {
|
||||
text: line.content.clone(),
|
||||
x: 0,
|
||||
y: bottom - bottom_to_text,
|
||||
color,
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
let bottom = chat_bottom - i as f32 * entry_height;
|
||||
let top = bottom - entry_height;
|
||||
let text_top = bottom - bottom_to_text;
|
||||
let a = (alpha * text_opacity * 255.0) as u8;
|
||||
if let Some(c) = line.indicator {
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
s(top),
|
||||
s(2.0),
|
||||
s(entry_height),
|
||||
rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, a),
|
||||
);
|
||||
}
|
||||
if a > 3 {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&line.content,
|
||||
s(4.0),
|
||||
s(text_top),
|
||||
rgba(255, 255, 255, a),
|
||||
true,
|
||||
);
|
||||
if let Some(indicator) = line.indicator {
|
||||
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
-2,
|
||||
bottom,
|
||||
color,
|
||||
));
|
||||
}
|
||||
}
|
||||
// Scroll bar while focused and scrolled content exists.
|
||||
if focused && count > 0 {
|
||||
let total = self.lines.len() as f32;
|
||||
let chat_height = count as f32 * entry_height;
|
||||
let virtual_height = total * entry_height;
|
||||
if focused {
|
||||
let count = lines.len() as i32;
|
||||
let chat_height = count * entry_height;
|
||||
let virtual_height = total as i32 * entry_height;
|
||||
let y = self.scroll as i32 * chat_height / total as i32 - chat_bottom;
|
||||
let height = chat_height * chat_height / virtual_height;
|
||||
if virtual_height != chat_height {
|
||||
let y = self.scroll as f32 * chat_height / total - chat_bottom;
|
||||
let height = chat_height * chat_height / virtual_height;
|
||||
let alpha = if y > 0.0 { 170 } else { 96 };
|
||||
let c = if self.new_since_scroll {
|
||||
let alpha: u32 = if y > 0 { 170 } else { 96 };
|
||||
let color = if self.new_since_scroll {
|
||||
0xCC3333
|
||||
} else {
|
||||
0x3333AA
|
||||
};
|
||||
let x = max_width + 4.0 + 4.0;
|
||||
batch.fill(
|
||||
atlas,
|
||||
s(x),
|
||||
s(-y - height),
|
||||
s(2.0),
|
||||
s(height),
|
||||
rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, alpha),
|
||||
);
|
||||
batch.fill(
|
||||
atlas,
|
||||
s(x + 1.0),
|
||||
s(-y - height),
|
||||
s(1.0),
|
||||
s(height),
|
||||
rgba(0xCC, 0xCC, 0xCC, alpha),
|
||||
);
|
||||
let x = max_width + 4;
|
||||
out.push(hud_layout::fill(
|
||||
x,
|
||||
-y,
|
||||
x + 2,
|
||||
-y - height,
|
||||
alpha << 24 | color,
|
||||
));
|
||||
out.push(hud_layout::fill(
|
||||
x + 2,
|
||||
-y,
|
||||
x + 1,
|
||||
-y - height,
|
||||
alpha << 24 | 0xCCCCCC,
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push(GuiDraw::Pop);
|
||||
out
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
/// Plain text of the newest messages (for tests and the control socket).
|
||||
pub fn recent_lines(&self, n: usize) -> Vec<String> {
|
||||
|
||||
@@ -78,6 +78,23 @@ impl SceneCollisions<'_, '_> {
|
||||
)?)
|
||||
}
|
||||
}
|
||||
/// `noCollision(null, box)`: block collisions under the empty collision
|
||||
/// context, as entity-less queries such as spawn checks use.
|
||||
pub fn no_collision_empty<W, E>(world: &mut W, bounds: Aabb) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
{
|
||||
no_world_collision(
|
||||
world,
|
||||
CollisionCheckRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::empty(false),
|
||||
bounds,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
impl BlockCollisionWorld for SceneCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
@@ -166,3 +183,91 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collision queries over any block lookup (test worlds without chunks):
|
||||
/// every chunk is present, nothing is suffocating, no entities collide.
|
||||
pub struct LookupCollisions<'w, 'r> {
|
||||
pub block: &'w dyn Fn(i32, i32, i32) -> horizon_world::registry::BlockStateId,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
}
|
||||
impl BlockCollisionWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<ChunkPos>> {
|
||||
Ok(Some(p))
|
||||
}
|
||||
fn collision_block(&mut self, _: ChunkPos, p: BlockPos) -> io::Result<CollisionBlock> {
|
||||
self.binding.collision_block((self.block)(p.x, p.y, p.z))
|
||||
}
|
||||
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
block: CollisionBlock,
|
||||
p: BlockPos,
|
||||
context: &CollisionContext<'_>,
|
||||
) -> io::Result<VoxelShape> {
|
||||
self.binding
|
||||
.collision_shape_with_context(self, block.state, p, context)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityMovementWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn moving_piston_shape(&mut self, _: [i32; 3]) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
|
||||
fn shulker_collision_state(
|
||||
&mut self,
|
||||
_: [i32; 3],
|
||||
) -> io::Result<Option<ShulkerCollisionState>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl LiquidCollisionWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn entity_liquid_shape(&mut self, _: EntityIdentity) -> io::Result<VoxelShape> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
fn fluid(&mut self, p: BlockPos) -> io::Result<FluidState> {
|
||||
FluidState::from_block(
|
||||
self.binding
|
||||
.registry()
|
||||
.state((self.block)(p.x, p.y, p.z))
|
||||
.ok_or_else(|| io::Error::other("fluid state outside registry"))?,
|
||||
)
|
||||
}
|
||||
fn entity_can_stand(&mut self, _: EntityIdentity, _: FluidState) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
impl PowderSnowWorld for LookupCollisions<'_, '_> {
|
||||
type Error = io::Error;
|
||||
fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result<PowderSnowEntity> {
|
||||
Ok(PowderSnowEntity {
|
||||
fall_distance: 0.0,
|
||||
falling_block: false,
|
||||
})
|
||||
}
|
||||
fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,73 @@ pub struct RegistryIds {
|
||||
}
|
||||
const EMPTY: &[String] = &[];
|
||||
impl Registries {
|
||||
/// Fill what the game did not build itself from vanilla's exported
|
||||
/// registries (`command-registries.json`, written by
|
||||
/// `VanillaCommandParseOracle registries`): built-in registries such as
|
||||
/// mob effects, data component types and dialogs, block entities and tag
|
||||
/// members. Registries that data packs reload stay with the game.
|
||||
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
|
||||
fn strings(v: &serde_json::Value) -> Vec<String> {
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
let fill = |field: &mut Vec<String>, key: &str| {
|
||||
if field.is_empty() {
|
||||
*field = strings(&r[key]);
|
||||
}
|
||||
};
|
||||
fill(&mut self.effects, "effects");
|
||||
fill(&mut self.enchantments, "enchantments");
|
||||
fill(&mut self.attributes, "attributes");
|
||||
fill(&mut self.damage_types, "damage_types");
|
||||
fill(&mut self.templates, "templates");
|
||||
fill(&mut self.persistent_components, "persistent_components");
|
||||
fill(&mut self.learnable_recipes, "learnable_recipes");
|
||||
const RELOADED: [&str; 5] = [
|
||||
"minecraft:advancement",
|
||||
"minecraft:recipe",
|
||||
"minecraft:loot_table",
|
||||
"minecraft:predicate",
|
||||
"minecraft:item_modifier",
|
||||
];
|
||||
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
|
||||
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
self.by_registry.insert(
|
||||
name.clone(),
|
||||
RegistryIds {
|
||||
ids: strings(&entry["ids"]),
|
||||
tags: strings(&entry["tags"]),
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.block_entities.is_empty() {
|
||||
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
|
||||
}
|
||||
if self.block_tag_members.is_empty() {
|
||||
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
|
||||
self.block_tag_members.insert(tag.clone(), strings(members));
|
||||
}
|
||||
}
|
||||
if self.clock_time_markers.is_empty() {
|
||||
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
|
||||
self.clock_time_markers
|
||||
.insert(clock.clone(), strings(markers));
|
||||
}
|
||||
}
|
||||
if self.advancement_criteria.is_empty() {
|
||||
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
|
||||
self.advancement_criteria
|
||||
.insert(id.clone(), strings(criteria));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
@@ -254,6 +321,47 @@ pub enum ArgType {
|
||||
SlotSource,
|
||||
/// ResourceOrIdArgument over a registry: an id or an inline SNBT value.
|
||||
ResourceOrId(&'static str),
|
||||
/// ResourceSelectorArgument: a `*`/`?` pattern over a registry's ids.
|
||||
ResourceSelector(&'static str),
|
||||
}
|
||||
|
||||
/// FilenameUtils.wildcardMatch (case-sensitive) of `*` and `?` patterns.
|
||||
pub fn wildcard_match(text: &str, pattern: &str) -> bool {
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
let (mut ti, mut pi) = (0, 0);
|
||||
let (mut star, mut mark) = (None, 0);
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
|
||||
ti += 1;
|
||||
pi += 1;
|
||||
} else if pi < p.len() && p[pi] == '*' {
|
||||
star = Some(pi);
|
||||
mark = ti;
|
||||
pi += 1;
|
||||
} else if let Some(s) = star {
|
||||
pi = s + 1;
|
||||
mark += 1;
|
||||
ti = mark;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while pi < p.len() && p[pi] == '*' {
|
||||
pi += 1;
|
||||
}
|
||||
pi == p.len()
|
||||
}
|
||||
/// ResourceSelectorArgument: the registry ids a pattern selects.
|
||||
pub fn select_resources<'a>(ids: &'a [String], pattern: &str) -> Vec<&'a String> {
|
||||
let pattern = if pattern.contains(':') {
|
||||
pattern.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{pattern}")
|
||||
};
|
||||
ids.iter()
|
||||
.filter(|id| wildcard_match(id, &pattern))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// SlotRanges.SLOTS: every slot range name and its size, in vanilla order.
|
||||
@@ -540,9 +648,9 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
|
||||
&& ns
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
|
||||
let path_ok = path
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
|
||||
let path_ok = path.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
|
||||
});
|
||||
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
pub fn normalize(id: &str) -> String {
|
||||
@@ -690,7 +798,11 @@ pub fn read_double_bounds(reader: &mut Reader) -> CmdResult<DoubleRange> {
|
||||
}
|
||||
/// MinMaxBounds.FloatDegrees.fromReader (no swap check).
|
||||
pub fn read_float_bounds(reader: &mut Reader) -> CmdResult<(Option<f32>, Option<f32>)> {
|
||||
read_bounds(reader, |s| java_parse_double(s).map(|v| v as f32), "parsing.float.invalid")
|
||||
read_bounds(
|
||||
reader,
|
||||
|s| java_parse_double(s).map(|v| v as f32),
|
||||
"parsing.float.invalid",
|
||||
)
|
||||
}
|
||||
/// Integer.parseInt over number characters.
|
||||
pub fn java_parse_int(s: &str) -> Option<i32> {
|
||||
@@ -886,7 +998,12 @@ fn java_split(s: &str) -> Vec<&str> {
|
||||
}
|
||||
/// SharedSuggestionProvider.suggestCoordinates / suggest2DCoordinates: the
|
||||
/// candidates `ty` parses completely.
|
||||
fn suggest_coordinates(builder: &mut SuggestionsBuilder, reg: &Registries, ty: &ArgType, relevant: bool) {
|
||||
fn suggest_coordinates(
|
||||
builder: &mut SuggestionsBuilder,
|
||||
reg: &Registries,
|
||||
ty: &ArgType,
|
||||
relevant: bool,
|
||||
) {
|
||||
let remaining = builder.remaining().to_owned();
|
||||
let three = matches!(ty, ArgType::BlockPos | ArgType::Vec3 { .. });
|
||||
let coords: [String; 3] = if remaining.starts_with('^') {
|
||||
@@ -903,7 +1020,11 @@ fn suggest_coordinates(builder: &mut SuggestionsBuilder, reg: &Registries, ty: &
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
let [x, y, z] = &coords;
|
||||
if remaining.is_empty() {
|
||||
let full = if three { format!("{x} {y} {z}") } else { format!("{x} {z}") };
|
||||
let full = if three {
|
||||
format!("{x} {y} {z}")
|
||||
} else {
|
||||
format!("{x} {z}")
|
||||
};
|
||||
if valid(&full) {
|
||||
result.push(x.clone());
|
||||
if three {
|
||||
@@ -965,7 +1086,11 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
|
||||
// StringReader.peek past the end (StringIndexOutOfBoundsException).
|
||||
let len = reader.string.encode_utf16().count();
|
||||
let message = format!("Index {len} out of bounds for length {len}");
|
||||
return Err(CommandError::tr_at("command.exception", &[&message], reader));
|
||||
return Err(CommandError::tr_at(
|
||||
"command.exception",
|
||||
&[&message],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
match reader.peek() {
|
||||
'{' => {
|
||||
@@ -988,7 +1113,10 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
|
||||
_ => {
|
||||
let s = reader.cursor;
|
||||
while reader.can_read()
|
||||
&& !matches!(reader.peek(), ' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}')
|
||||
&& !matches!(
|
||||
reader.peek(),
|
||||
' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}'
|
||||
)
|
||||
{
|
||||
reader.skip();
|
||||
}
|
||||
@@ -1049,10 +1177,9 @@ fn particle_options(id: &str, t: &horizon_world::nbt::Tag) -> Result<(), String>
|
||||
{
|
||||
// withAlternative(BlockState.CODEC, the block id codec).
|
||||
let first = match state {
|
||||
horizon_world::nbt::Tag::Compound(_) => super::codec::either(
|
||||
"Not a string".into(),
|
||||
no_key("id", state),
|
||||
),
|
||||
horizon_world::nbt::Tag::Compound(_) => {
|
||||
super::codec::either("Not a string".into(), no_key("id", state))
|
||||
}
|
||||
_ => not_map(state),
|
||||
};
|
||||
let _ = snbt;
|
||||
@@ -1121,7 +1248,10 @@ impl ArgType {
|
||||
reader.cursor = start;
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.double.low",
|
||||
&[&crate::snbt::java_double(*min), &crate::snbt::java_double(v)],
|
||||
&[
|
||||
&crate::snbt::java_double(*min),
|
||||
&crate::snbt::java_double(v),
|
||||
],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
@@ -1129,7 +1259,10 @@ impl ArgType {
|
||||
reader.cursor = start;
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.double.big",
|
||||
&[&crate::snbt::java_double(*max), &crate::snbt::java_double(v)],
|
||||
&[
|
||||
&crate::snbt::java_double(*max),
|
||||
&crate::snbt::java_double(v),
|
||||
],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
@@ -1146,10 +1279,7 @@ impl ArgType {
|
||||
// MessageArgument.Message.parseText.
|
||||
let len = reader.remaining().encode_utf16().count();
|
||||
if len > 256 {
|
||||
return Err(CommandError::tr(
|
||||
"argument.message.too_long",
|
||||
&[&len, &256],
|
||||
));
|
||||
return Err(CommandError::tr("argument.message.too_long", &[&len, &256]));
|
||||
}
|
||||
let s = reader.remaining().to_owned();
|
||||
if reg.permission >= 2 {
|
||||
@@ -1326,10 +1456,37 @@ impl ArgType {
|
||||
}
|
||||
Arg::Str(name.to_owned())
|
||||
}
|
||||
ArgType::ResourceSelector(registry) => {
|
||||
let start = reader.cursor;
|
||||
while reader.can_read() && {
|
||||
let c = reader.peek();
|
||||
is_id_char(c) || c == '*' || c == '?'
|
||||
} {
|
||||
reader.skip();
|
||||
}
|
||||
let raw = reader.string[start..reader.cursor].to_owned();
|
||||
let pattern = if raw.contains(':') {
|
||||
raw
|
||||
} else {
|
||||
format!("minecraft:{raw}")
|
||||
};
|
||||
if select_resources(reg.ids(registry), &pattern).is_empty() {
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.resource_selector.not_found",
|
||||
&[&pattern, registry],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
Arg::Str(pattern)
|
||||
}
|
||||
ArgType::HexColor => {
|
||||
let text = reader.read_unquoted_string();
|
||||
if !matches!(text.len(), 3 | 6) {
|
||||
return Err(CommandError::tr_at("argument.hexcolor.invalid", &[&text], reader));
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.hexcolor.invalid",
|
||||
&[&text],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
// Integer.parseInt(s, begin, end, 16) per channel: a bad digit
|
||||
// escapes Brigadier as a dispatcher parse exception.
|
||||
@@ -1341,7 +1498,11 @@ impl ArgType {
|
||||
if let Some(i) = digits.find(|ch: char| !ch.is_ascii_hexdigit()) {
|
||||
let index = i + usize::from(signed);
|
||||
let message = format!("Error at index {index} in: \"{c}\"");
|
||||
return Err(CommandError::tr_at("command.exception", &[&message], reader));
|
||||
return Err(CommandError::tr_at(
|
||||
"command.exception",
|
||||
&[&message],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
}
|
||||
let v = i64::from_str_radix(text, 16).unwrap_or(0);
|
||||
@@ -1361,7 +1522,10 @@ impl ArgType {
|
||||
.take_while(|c| c.is_ascii_hexdigit() || *c == '-')
|
||||
.map(char::len_utf8)
|
||||
.sum();
|
||||
match (run > 0).then(|| super::selector::java_uuid(&rest[..run])).flatten() {
|
||||
match (run > 0)
|
||||
.then(|| super::selector::java_uuid(&rest[..run]))
|
||||
.flatten()
|
||||
{
|
||||
Some(_) => {
|
||||
let text = rest[..run].to_owned();
|
||||
reader.cursor += run;
|
||||
@@ -1391,7 +1555,11 @@ impl ArgType {
|
||||
let v = reader.read_float()?;
|
||||
let unit = reader.read_unquoted_string();
|
||||
let Some(m) = time_unit(unit) else {
|
||||
return Err(CommandError::tr_at("argument.time.invalid_unit", &[], reader));
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.time.invalid_unit",
|
||||
&[],
|
||||
reader,
|
||||
));
|
||||
};
|
||||
// Math.round(float): floor(x + 0.5).
|
||||
let ticks = (v * m as f32 + 0.5).floor() as i32;
|
||||
@@ -1425,7 +1593,10 @@ impl ArgType {
|
||||
// ParserBasedArgument.withCodec: SNBT, then the codec (errors at the start).
|
||||
let tag = super::snbt::parse_tag_argument(reader)?;
|
||||
let (decoded, key) = if matches!(self, ArgType::Style) {
|
||||
(super::component::decode_style(&tag), "argument.style.invalid")
|
||||
(
|
||||
super::component::decode_style(&tag),
|
||||
"argument.style.invalid",
|
||||
)
|
||||
} else {
|
||||
(super::component::decode(&tag), "argument.component.invalid")
|
||||
};
|
||||
@@ -1481,15 +1652,26 @@ impl ArgType {
|
||||
reader.skip();
|
||||
}
|
||||
let id = &reader.string[start..reader.cursor];
|
||||
if !crate::scoreboard::criteria_exists(id, reg) {
|
||||
reader.cursor = start;
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.criteria.invalid",
|
||||
&[&id],
|
||||
reader,
|
||||
));
|
||||
// ObjectiveCriteria.byName: statistics resolve to their canonical name.
|
||||
match crate::scoreboard::criteria_by_name(id, reg) {
|
||||
Ok(Some(canonical)) => Arg::Str(canonical),
|
||||
Ok(None) => {
|
||||
reader.cursor = start;
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.criteria.invalid",
|
||||
&[&id],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
reader.cursor = start;
|
||||
return Err(CommandError::tr_at(
|
||||
"argument.criteria.invalid",
|
||||
&[&id],
|
||||
reader,
|
||||
));
|
||||
}
|
||||
}
|
||||
Arg::Str(id.to_owned())
|
||||
}
|
||||
ArgType::ScoreboardSlot => {
|
||||
let name = reader.read_unquoted_string();
|
||||
@@ -1539,7 +1721,8 @@ impl ArgType {
|
||||
if block {
|
||||
parse_coord_int(reader)
|
||||
} else {
|
||||
parse_coord_double(reader, false)
|
||||
// Vec2Argument.vec2(): integers are centred on the block.
|
||||
parse_coord_double(reader, true)
|
||||
}
|
||||
};
|
||||
let x = one(reader)?;
|
||||
@@ -1701,6 +1884,9 @@ impl ArgType {
|
||||
}
|
||||
ArgType::StrEnum(values, _) => builder.suggest_matching(values.iter().copied()),
|
||||
ArgType::HexColor => builder.suggest_matching(["F00", "FF0000"]),
|
||||
ArgType::ResourceSelector(registry) => {
|
||||
suggest_resources(builder, reg.ids(registry), "")
|
||||
}
|
||||
ArgType::GameProfile => {
|
||||
let players = reg.players.clone();
|
||||
super::selector::suggest(builder, reg, &|b| {
|
||||
|
||||
@@ -379,7 +379,8 @@ impl<'r, 'a> BlockParser<'r, 'a> {
|
||||
}
|
||||
let prefix = b.remaining_lower();
|
||||
for (name, _) in definition {
|
||||
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix) {
|
||||
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix)
|
||||
{
|
||||
b.suggest(format!("{name}="));
|
||||
}
|
||||
}
|
||||
@@ -439,11 +440,7 @@ impl<'r, 'a> BlockParser<'r, 'a> {
|
||||
}
|
||||
|
||||
/// BlockStateParser.parseForBlock / parseForTesting (the reader is rewound on errors).
|
||||
pub fn parse(
|
||||
reader: &mut Reader,
|
||||
reg: &Registries,
|
||||
for_testing: bool,
|
||||
) -> CmdResult<BlockInput> {
|
||||
pub fn parse(reader: &mut Reader, reg: &Registries, for_testing: bool) -> CmdResult<BlockInput> {
|
||||
let cursor = reader.cursor;
|
||||
let r = BlockParser::new(reader, reg, for_testing, true).parse();
|
||||
if r.is_err() {
|
||||
|
||||
@@ -77,19 +77,28 @@ fn require(map: &Compound, t: &Tag, keys: &[&str]) -> Result<(), String> {
|
||||
Err(missing.join("; "))
|
||||
}
|
||||
}
|
||||
/// The required fields of each context number provider type, in the
|
||||
/// order DataFixerUpper reports them missing (the reverse of the record's).
|
||||
fn provider_fields(id: &str, map: &Compound, t: &Tag) -> Result<(), String> {
|
||||
match id {
|
||||
"minecraft:uniform" => require(map, t, &["max", "min"]),
|
||||
"minecraft:constant" => require(map, t, &["value"]),
|
||||
"minecraft:binomial" => require(map, t, &["n", "p"]),
|
||||
match id.trim_start_matches("minecraft:") {
|
||||
"uniform" => require(map, t, &["max", "min"]),
|
||||
"constant" => require(map, t, &["value"]),
|
||||
"binomial" => require(map, t, &["p", "n"]),
|
||||
"add" | "mul" | "avg" | "max" | "min" | "length" => require(map, t, &["inputs"]),
|
||||
"sub" | "div" | "mod" | "floor_div" | "floor_mod" => require(map, t, &["right", "left"]),
|
||||
"pow" => require(map, t, &["exponent", "base"]),
|
||||
"abs" | "negate" | "from_float" | "from_int" | "floor" | "ceil" | "round" | "sqrt"
|
||||
| "sin" | "cos" | "truncate" => require(map, t, &["input"]),
|
||||
"conditional" => require(map, t, &["on_true", "condition"]),
|
||||
"number_dispatcher" => require(map, t, &["cases"]),
|
||||
"weighted_list" => require(map, t, &["distribution"]),
|
||||
"enchantment_level" => require(map, t, &["amount"]),
|
||||
"score" => require(map, t, &["score", "target"]),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
/// A holder list codec: a single element, a `#tag`, or a list.
|
||||
fn element_or_list(
|
||||
t: &Tag,
|
||||
element: &dyn Fn(&Tag) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
fn element_or_list(t: &Tag, element: &dyn Fn(&Tag) -> Result<(), String>) -> Result<(), String> {
|
||||
let first = match element(t) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => e,
|
||||
@@ -133,7 +142,19 @@ pub fn decode(registry: &str, t: &Tag, reg: &Registries) -> Result<(), String> {
|
||||
"minecraft:slot_source" => element_or_list(t, &|x| {
|
||||
dispatch(x, reg, "minecraft:slot_source_type", &|_, _, _| Ok(()))
|
||||
}),
|
||||
"minecraft:dialog" => dispatch(t, reg, "minecraft:dialog_type", &|_, _, _| Ok(())),
|
||||
// Dialog types: CommonDialogData's title, then each type's own.
|
||||
"minecraft:dialog" => dispatch(t, reg, "minecraft:dialog_type", &|id, map, t| {
|
||||
let own: &[&str] = match id {
|
||||
"minecraft:confirmation" => &["yes", "no"],
|
||||
"minecraft:multi_action" => &["actions"],
|
||||
"minecraft:dialog_list" => &["dialogs"],
|
||||
_ => &[],
|
||||
};
|
||||
let keys: Vec<&str> = std::iter::once("title")
|
||||
.chain(own.iter().copied())
|
||||
.collect();
|
||||
require(map, t, &keys)
|
||||
}),
|
||||
"minecraft:worldgen/feature" | "minecraft:loot_table" => match t {
|
||||
Tag::Compound(_) => Ok(()),
|
||||
_ => Err(not_map(t)),
|
||||
@@ -179,7 +200,6 @@ pub fn resource_or_id(reader: &mut Reader, reg: &Registries, registry: &str) ->
|
||||
}
|
||||
reader.cursor = start;
|
||||
let tag = super::snbt::parse_tag_argument(reader)?;
|
||||
decode(registry, &tag, reg).map_err(|m| {
|
||||
CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader)
|
||||
})
|
||||
decode(registry, &tag, reg)
|
||||
.map_err(|m| CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader))
|
||||
}
|
||||
@@ -47,7 +47,11 @@ fn is_string(t: &Tag) -> bool {
|
||||
matches!(t, Tag::String(_))
|
||||
}
|
||||
fn string(t: &Tag) -> R {
|
||||
if is_string(t) { Ok(()) } else { err("Not a string") }
|
||||
if is_string(t) {
|
||||
Ok(())
|
||||
} else {
|
||||
err("Not a string")
|
||||
}
|
||||
}
|
||||
fn string_of(t: &Tag) -> Option<String> {
|
||||
match t {
|
||||
@@ -131,9 +135,9 @@ pub fn identifier(t: &Tag) -> Result<(), String> {
|
||||
"Not a valid resource location: {s} Non [a-z0-9_.-] character in namespace of location: {ns}:{path}"
|
||||
));
|
||||
}
|
||||
let path_ok = path
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
|
||||
let path_ok = path.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
|
||||
});
|
||||
if !path_ok {
|
||||
return Err(format!(
|
||||
"Not a valid resource location: {s} Non [a-z0-9/._-] character in path of location: {ns}:{path}"
|
||||
@@ -208,7 +212,13 @@ fn style_fields(map: &Compound) -> R {
|
||||
if let Some(c) = get(map, "color") {
|
||||
color(c)?;
|
||||
}
|
||||
for key in ["bold", "italic", "underlined", "strikethrough", "obfuscated"] {
|
||||
for key in [
|
||||
"bold",
|
||||
"italic",
|
||||
"underlined",
|
||||
"strikethrough",
|
||||
"obfuscated",
|
||||
] {
|
||||
if let Some(v) = get(map, key)
|
||||
&& bool_value(v).is_err()
|
||||
{
|
||||
@@ -262,12 +272,13 @@ fn content(kind: &str, map: &Compound) -> bool {
|
||||
_ => false,
|
||||
},
|
||||
"selector" => {
|
||||
get(map, "selector").is_some_and(selector_string) && optional_component(map, "separator")
|
||||
get(map, "selector").is_some_and(selector_string)
|
||||
&& optional_component(map, "separator")
|
||||
}
|
||||
"nbt" => {
|
||||
let path_ok = get(map, "nbt").and_then(string_of).is_some_and(|p| {
|
||||
crate::nbt_path::NbtPath::parse(&p).is_ok()
|
||||
});
|
||||
let path_ok = get(map, "nbt")
|
||||
.and_then(string_of)
|
||||
.is_some_and(|p| crate::nbt_path::NbtPath::parse(&p).is_ok());
|
||||
let source = if let Some(e) = get(map, "entity") {
|
||||
selector_string(e)
|
||||
} else if let Some(b) = get(map, "block") {
|
||||
@@ -336,10 +347,9 @@ fn full(t: &Tag) -> R {
|
||||
style_fields(map)
|
||||
}
|
||||
fn component(t: &Tag) -> R {
|
||||
either(
|
||||
either(string(t), || non_empty_list(t, &component)),
|
||||
|| full(t),
|
||||
)
|
||||
either(either(string(t), || non_empty_list(t, &component)), || {
|
||||
full(t)
|
||||
})
|
||||
}
|
||||
/// ComponentSerialization.CODEC.parse: the error message on failure.
|
||||
pub fn decode(t: &Tag) -> Result<(), String> {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! ItemParser (item stacks with components, `stone[damage=5]`) and
|
||||
//! ItemPredicateArgument (ComponentPredicateParser's packrat grammar:
|
||||
//! `#logs[count~1..|!damage]`), with vanilla's errors and suggestions.
|
||||
use super::args::{ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources};
|
||||
use super::args::{
|
||||
ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources,
|
||||
};
|
||||
use super::codec::{either, is_number, not_map, snbt};
|
||||
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, opt, seq};
|
||||
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
|
||||
@@ -159,7 +161,10 @@ impl ItemState<'_, '_> {
|
||||
self.sugg = ISugg::Component;
|
||||
let ty = self.component_type()?;
|
||||
if visited.contains(&ty) {
|
||||
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
|
||||
return Err(CommandError::tr(
|
||||
"arguments.item.component.repeated",
|
||||
&[&ty],
|
||||
));
|
||||
}
|
||||
visited.push(ty);
|
||||
self.sugg = ISugg::Nothing;
|
||||
@@ -167,7 +172,10 @@ impl ItemState<'_, '_> {
|
||||
} else {
|
||||
let ty = self.component_type()?;
|
||||
if visited.contains(&ty) {
|
||||
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
|
||||
return Err(CommandError::tr(
|
||||
"arguments.item.component.repeated",
|
||||
&[&ty],
|
||||
));
|
||||
}
|
||||
visited.push(ty.clone());
|
||||
self.sugg = ISugg::Assignment;
|
||||
@@ -401,10 +409,12 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
"type",
|
||||
alt(vec![element, seq(vec![ch('#'), Term::Cut, tag]), any]),
|
||||
|s| {
|
||||
Some(IV::Type(match s.scope.get_any(&["element_type", "tag_type"]) {
|
||||
Some(IV::Elem(t)) => Some(t),
|
||||
_ => None,
|
||||
}))
|
||||
Some(IV::Type(
|
||||
match s.scope.get_any(&["element_type", "tag_type"]) {
|
||||
Some(IV::Elem(t)) => Some(t),
|
||||
_ => None,
|
||||
},
|
||||
))
|
||||
},
|
||||
);
|
||||
g.put_term("any_type", ch('*'), |_| Some(IV::Unit));
|
||||
@@ -415,7 +425,11 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
if r.ids("minecraft:item").iter().any(|i| i == id) {
|
||||
Ok(IV::Elem(TypeRes::Item(id.to_owned())))
|
||||
} else {
|
||||
Err(CommandError::tr_at("argument.item.id.invalid", &[&id], reader))
|
||||
Err(CommandError::tr_at(
|
||||
"argument.item.id.invalid",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -426,7 +440,11 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
if r.tags("minecraft:item").iter().any(|i| i == id) {
|
||||
Ok(IV::Elem(TypeRes::Tag(id.to_owned())))
|
||||
} else {
|
||||
Err(CommandError::tr_at("arguments.item.tag.unknown", &[&id], reader))
|
||||
Err(CommandError::tr_at(
|
||||
"arguments.item.tag.unknown",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -509,29 +527,49 @@ fn predicate_grammar() -> Grammar<IV> {
|
||||
if id == "minecraft:count" {
|
||||
return Ok(IV::Comp(id.to_owned()));
|
||||
}
|
||||
let known = r.ids("minecraft:data_component_type").iter().any(|c| c == id);
|
||||
let known = r
|
||||
.ids("minecraft:data_component_type")
|
||||
.iter()
|
||||
.any(|c| c == id);
|
||||
if known && r.persistent_components.iter().any(|c| c == id) {
|
||||
Ok(IV::Comp(id.to_owned()))
|
||||
} else {
|
||||
Err(CommandError::tr_at("arguments.item.component.unknown", &[&id], reader))
|
||||
Err(CommandError::tr_at(
|
||||
"arguments.item.component.unknown",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
}
|
||||
})
|
||||
});
|
||||
g.put_custom("predicate_type", move |s| {
|
||||
let r = reg(s);
|
||||
let mut list = vec!["minecraft:count".to_owned()];
|
||||
list.extend(r.ids("minecraft:data_component_predicate_type").iter().cloned());
|
||||
list.extend(
|
||||
r.ids("minecraft:data_component_predicate_type")
|
||||
.iter()
|
||||
.cloned(),
|
||||
);
|
||||
lookup(s, id, Rc::new(list), &|id, reader| {
|
||||
if id == "minecraft:count"
|
||||
|| r.ids("minecraft:data_component_predicate_type").iter().any(|c| c == id)
|
||||
|| r.ids("minecraft:data_component_predicate_type")
|
||||
.iter()
|
||||
.any(|c| c == id)
|
||||
{
|
||||
return Ok(IV::Pred(id.to_owned()));
|
||||
}
|
||||
if r.ids("minecraft:data_component_type").iter().any(|c| c == id) {
|
||||
if r.ids("minecraft:data_component_type")
|
||||
.iter()
|
||||
.any(|c| c == id)
|
||||
{
|
||||
// A component existence predicate (decodes Unit).
|
||||
return Ok(IV::Pred(format!("exists:{id}")));
|
||||
}
|
||||
Err(CommandError::tr_at("arguments.item.predicate.unknown", &[&id], reader))
|
||||
Err(CommandError::tr_at(
|
||||
"arguments.item.predicate.unknown",
|
||||
&[&id],
|
||||
reader,
|
||||
))
|
||||
})
|
||||
});
|
||||
g.put_custom("tag", |s| super::snbt::rule_in(s, IV::Tag));
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod tree_misc;
|
||||
pub mod tree_scoreboard;
|
||||
pub mod tree_world;
|
||||
pub mod vanilla;
|
||||
mod vanilla_order;
|
||||
|
||||
use crate::text::Text;
|
||||
pub use args::{Arg, ArgType, Registries};
|
||||
@@ -616,7 +617,8 @@ pub fn merge_suggestions(input: &str, list: Vec<Suggestion>) -> (Range, Vec<Sugg
|
||||
// A stable insertion sort (the comparator mixes numeric and text order).
|
||||
let mut sorted: Vec<Suggestion> = Vec::with_capacity(out.len());
|
||||
for s in out {
|
||||
let pos = sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
|
||||
let pos =
|
||||
sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
|
||||
sorted.insert(pos, s);
|
||||
}
|
||||
((start, end), sorted)
|
||||
@@ -644,7 +646,8 @@ impl Suggests {
|
||||
for id in ®.summonable {
|
||||
if args::resource_matches(&contents, id) {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let name = crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
|
||||
let name =
|
||||
crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
|
||||
builder.suggest_tooltip(id.clone(), name);
|
||||
}
|
||||
}
|
||||
@@ -673,7 +676,9 @@ impl Suggests {
|
||||
.trigger_scores
|
||||
.iter()
|
||||
.filter(|(_, scores)| {
|
||||
targets.iter().any(|t| scores.get(t).is_none_or(|locked| *locked))
|
||||
targets
|
||||
.iter()
|
||||
.any(|t| scores.get(t).is_none_or(|locked| *locked))
|
||||
})
|
||||
.map(|(o, _)| o.as_str())
|
||||
.collect();
|
||||
|
||||
@@ -94,13 +94,11 @@ impl<V: Clone> Scope<V> {
|
||||
let current = self.top_marker;
|
||||
let n = self.top_entry - self.top_marker;
|
||||
self.setup_new_frame();
|
||||
let mut source = current + 1;
|
||||
let mut target = self.top_entry;
|
||||
for _ in 0..n {
|
||||
for source in current + 1..current + 1 + n {
|
||||
target += 1;
|
||||
let key = self.stack[source].0;
|
||||
self.set(target, key, None);
|
||||
source += 1;
|
||||
}
|
||||
self.top_entry = target;
|
||||
}
|
||||
@@ -139,11 +137,13 @@ impl<V: Clone> Scope<V> {
|
||||
}
|
||||
}
|
||||
pub fn get(&self, atom: Atom) -> Option<V> {
|
||||
self.value_index(&[atom]).and_then(|i| self.stack[i].1.clone())
|
||||
self.value_index(&[atom])
|
||||
.and_then(|i| self.stack[i].1.clone())
|
||||
}
|
||||
/// Scope.getAny: the newest of several atoms.
|
||||
pub fn get_any(&self, atoms: &[Atom]) -> Option<V> {
|
||||
self.value_index(atoms).and_then(|i| self.stack[i].1.clone())
|
||||
self.value_index(atoms)
|
||||
.and_then(|i| self.stack[i].1.clone())
|
||||
}
|
||||
/// Whether the atom has an entry (even a null one).
|
||||
pub fn has(&self, atom: Atom) -> bool {
|
||||
@@ -385,7 +385,7 @@ impl<'g, V: Value> State<'g, V> {
|
||||
r
|
||||
}
|
||||
fn first_error(&self) -> CommandError {
|
||||
for (_, reason) in &self.errors {
|
||||
if let Some((_, reason)) = self.errors.first() {
|
||||
return match reason {
|
||||
Reason::Delayed(key, args) => {
|
||||
let args: Vec<&dyn std::fmt::Display> =
|
||||
|
||||
@@ -143,7 +143,12 @@ pub fn java_uuid(name: &str) -> Option<u128> {
|
||||
Some((u128::from(msb) << 64) | u128::from(lsb))
|
||||
}
|
||||
|
||||
fn rollback(reader: &mut Reader, start: usize, key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
|
||||
fn rollback(
|
||||
reader: &mut Reader,
|
||||
start: usize,
|
||||
key: &str,
|
||||
args: &[&dyn std::fmt::Display],
|
||||
) -> CommandError {
|
||||
reader.cursor = start;
|
||||
CommandError::tr_at(key, args, reader)
|
||||
}
|
||||
|
||||
@@ -153,7 +153,9 @@ fn parse_small(s: &str, radix: u32, lo: i32, hi: i32) -> Result<i32, String> {
|
||||
}
|
||||
fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
|
||||
if s.starts_with('-') {
|
||||
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
|
||||
return Err(format!(
|
||||
"Illegal leading minus sign on unsigned string {s}."
|
||||
));
|
||||
}
|
||||
let len = s.chars().count();
|
||||
if len <= 5 || (radix == 10 && len <= 9) {
|
||||
@@ -168,7 +170,9 @@ fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
|
||||
}
|
||||
fn parse_unsigned_long(s: &str, radix: u32) -> Result<i64, String> {
|
||||
if s.starts_with('-') {
|
||||
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
|
||||
return Err(format!(
|
||||
"Illegal leading minus sign on unsigned string {s}."
|
||||
));
|
||||
}
|
||||
let digits = s.strip_prefix('+').unwrap_or(s);
|
||||
match u64::from_str_radix(digits, radix) {
|
||||
@@ -338,7 +342,11 @@ pub fn grammar() -> Grammar<SV> {
|
||||
|s| s.scope.get("integer_suffix"),
|
||||
);
|
||||
g.put_custom("binary_numeral", |s| {
|
||||
number_run(s, |c| matches!(c, '0' | '1' | '_'), "snbt.parser.expected_binary_numeral")
|
||||
number_run(
|
||||
s,
|
||||
|c| matches!(c, '0' | '1' | '_'),
|
||||
"snbt.parser.expected_binary_numeral",
|
||||
)
|
||||
});
|
||||
g.put_custom("decimal_numeral", |s| {
|
||||
number_run(
|
||||
@@ -394,7 +402,10 @@ pub fn grammar() -> Grammar<SV> {
|
||||
} else if let Some(h) = get_str(s, "hex_numeral") {
|
||||
(Base::Hex, h)
|
||||
} else {
|
||||
(Base::Binary, get_str(s, "binary_numeral").unwrap_or_default())
|
||||
(
|
||||
Base::Binary,
|
||||
get_str(s, "binary_numeral").unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
Some(SV::Int(IntLit {
|
||||
minus,
|
||||
@@ -408,8 +419,14 @@ pub fn grammar() -> Grammar<SV> {
|
||||
g.put_term(
|
||||
"float_type_suffix",
|
||||
alt(vec![
|
||||
seq(vec![chs('f', 'F'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float))]),
|
||||
seq(vec![chs('d', 'D'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double))]),
|
||||
seq(vec![
|
||||
chs('f', 'F'),
|
||||
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float)),
|
||||
]),
|
||||
seq(vec![
|
||||
chs('d', 'D'),
|
||||
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double)),
|
||||
]),
|
||||
]),
|
||||
|s| s.scope.get("float_type_suffix"),
|
||||
);
|
||||
@@ -420,7 +437,10 @@ pub fn grammar() -> Grammar<SV> {
|
||||
seq(vec![chs('e', 'E'), opt(sign), decimal]),
|
||||
|s| {
|
||||
let minus = matches!(s.scope.get("sign"), Some(SV::Sign(true)));
|
||||
Some(SV::Exp(minus, get_str(s, "decimal_numeral").unwrap_or_default()))
|
||||
Some(SV::Exp(
|
||||
minus,
|
||||
get_str(s, "decimal_numeral").unwrap_or_default(),
|
||||
))
|
||||
},
|
||||
);
|
||||
let sign = g.named("sign");
|
||||
@@ -470,7 +490,11 @@ pub fn grammar() -> Grammar<SV> {
|
||||
float_tag(&text, ty, s).map(SV::Tag)
|
||||
},
|
||||
);
|
||||
for (name, n) in [("string_hex_2", 2), ("string_hex_4", 4), ("string_hex_8", 8)] {
|
||||
for (name, n) in [
|
||||
("string_hex_2", 2),
|
||||
("string_hex_4", 4),
|
||||
("string_hex_8", 8),
|
||||
] {
|
||||
g.put_custom(name, move |s| {
|
||||
greedy(
|
||||
s,
|
||||
@@ -521,7 +545,9 @@ pub fn grammar() -> Grammar<SV> {
|
||||
if let Some(SV::Str(plain)) = s.scope.get_any(&["string_escape_sequence"]) {
|
||||
return Some(SV::Str(plain));
|
||||
}
|
||||
if let Some(SV::Str(h)) = s.scope.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
|
||||
if let Some(SV::Str(h)) =
|
||||
s.scope
|
||||
.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
|
||||
{
|
||||
let cp = u32::from_str_radix(&h, 16).unwrap_or(u32::MAX);
|
||||
if cp > 0x10ffff {
|
||||
@@ -531,7 +557,9 @@ pub fn grammar() -> Grammar<SV> {
|
||||
));
|
||||
return None;
|
||||
}
|
||||
return Some(SV::Str(char::from_u32(cp).unwrap_or('\u{fffd}').to_string()));
|
||||
return Some(SV::Str(
|
||||
char::from_u32(cp).unwrap_or('\u{fffd}').to_string(),
|
||||
));
|
||||
}
|
||||
// Character.codePointOf: Unicode names are not tabulated here.
|
||||
s.store_at_mark(delayed("snbt.parser.invalid_character_name"));
|
||||
@@ -663,7 +691,8 @@ pub fn grammar() -> Grammar<SV> {
|
||||
let qs = g.named("quoted_string_literal");
|
||||
let uq = g.named("unquoted_string");
|
||||
g.put_term("map_key", alt(vec![qs, uq]), |s| {
|
||||
s.scope.get_any(&["quoted_string_literal", "unquoted_string"])
|
||||
s.scope
|
||||
.get_any(&["quoted_string_literal", "unquoted_string"])
|
||||
});
|
||||
let key = g.named("map_key");
|
||||
let lit = g.named("literal");
|
||||
@@ -712,9 +741,18 @@ pub fn grammar() -> Grammar<SV> {
|
||||
g.put_term(
|
||||
"array_prefix",
|
||||
alt(vec![
|
||||
seq(vec![ch('B'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte))]),
|
||||
seq(vec![ch('L'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long))]),
|
||||
seq(vec![ch('I'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int))]),
|
||||
seq(vec![
|
||||
ch('B'),
|
||||
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte)),
|
||||
]),
|
||||
seq(vec![
|
||||
ch('L'),
|
||||
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long)),
|
||||
]),
|
||||
seq(vec![
|
||||
ch('I'),
|
||||
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int)),
|
||||
]),
|
||||
]),
|
||||
|s| s.scope.get("array_prefix"),
|
||||
);
|
||||
@@ -890,7 +928,11 @@ thread_local! {
|
||||
pub fn parse_tag_argument(reader: &mut Reader) -> CmdResult<Tag> {
|
||||
GRAMMAR.with(|g| match g.parse_for_commands(reader)? {
|
||||
SV::Tag(t) => Ok(t),
|
||||
_ => Err(CommandError::tr_at("argument.nbt.expected.compound", &[], reader)),
|
||||
_ => Err(CommandError::tr_at(
|
||||
"argument.nbt.expected.compound",
|
||||
&[],
|
||||
reader,
|
||||
)),
|
||||
})
|
||||
}
|
||||
/// TagParser.parseCompoundAsArgument.
|
||||
|
||||
@@ -37,9 +37,18 @@ fn reg() -> Registries {
|
||||
},
|
||||
);
|
||||
};
|
||||
registry("minecraft:worldgen/biome", &["minecraft:desert", "minecraft:plains"]);
|
||||
registry("minecraft:damage_type", &["minecraft:fall", "minecraft:magic"]);
|
||||
registry("minecraft:attribute", &["minecraft:max_health", "minecraft:scale"]);
|
||||
registry(
|
||||
"minecraft:worldgen/biome",
|
||||
&["minecraft:desert", "minecraft:plains"],
|
||||
);
|
||||
registry(
|
||||
"minecraft:damage_type",
|
||||
&["minecraft:fall", "minecraft:magic"],
|
||||
);
|
||||
registry(
|
||||
"minecraft:attribute",
|
||||
&["minecraft:max_health", "minecraft:scale"],
|
||||
);
|
||||
registry("minecraft:item_modifier", &["a:b"]);
|
||||
registry("minecraft:predicate", &["a:b"]);
|
||||
registry(
|
||||
@@ -54,7 +63,14 @@ fn reg() -> Registries {
|
||||
"minecraft:entity_type",
|
||||
&["minecraft:pig", "minecraft:player", "minecraft:zombie"],
|
||||
);
|
||||
registry("minecraft:item", &["minecraft:diamond", "minecraft:diamond_sword", "minecraft:stone"]);
|
||||
registry(
|
||||
"minecraft:item",
|
||||
&[
|
||||
"minecraft:diamond",
|
||||
"minecraft:diamond_sword",
|
||||
"minecraft:stone",
|
||||
],
|
||||
);
|
||||
r
|
||||
}
|
||||
fn run(input: &str) -> CmdResult<(String, Vec<String>)> {
|
||||
@@ -436,7 +452,10 @@ fn world_command_trees() {
|
||||
"particle minecraft:dust{color:[1.0,0.0,0.0],scale:2.0} ~ ~ ~ 0.1 0.1 0.1 0 10 force @a",
|
||||
"particle",
|
||||
),
|
||||
("particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~", "particle"),
|
||||
(
|
||||
"particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~",
|
||||
"particle",
|
||||
),
|
||||
("attribute @s minecraft:max_health get 2", "attribute.get"),
|
||||
(
|
||||
"attribute @s minecraft:max_health base set 40",
|
||||
|
||||
@@ -52,7 +52,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
d.requires(compute, GAMEMASTERS);
|
||||
context_sources(d, compute, &mut |d, node| {
|
||||
let f = d.literal(node, "float");
|
||||
let p = d.argument(f, "provider", ArgType::ResourceOrId("minecraft:context_float_provider"));
|
||||
let p = d.argument(
|
||||
f,
|
||||
"provider",
|
||||
ArgType::ResourceOrId("minecraft:context_float_provider"),
|
||||
);
|
||||
d.executes(p, "compute");
|
||||
let scale = d.argument(
|
||||
p,
|
||||
@@ -64,7 +68,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
);
|
||||
d.executes(scale, "compute");
|
||||
let i = d.literal(node, "integer");
|
||||
let p = d.argument(i, "provider", ArgType::ResourceOrId("minecraft:context_int_provider"));
|
||||
let p = d.argument(
|
||||
i,
|
||||
"provider",
|
||||
ArgType::ResourceOrId("minecraft:context_int_provider"),
|
||||
);
|
||||
d.executes(p, "compute");
|
||||
});
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
let t = d.argument(as_, "targets", ENTITIES);
|
||||
d.redirect(t, ex);
|
||||
let over = d.literal(positioned, "over");
|
||||
let hm = d.argument(over, "heightmap", ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"));
|
||||
let hm = d.argument(
|
||||
over,
|
||||
"heightmap",
|
||||
ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"),
|
||||
);
|
||||
d.redirect(hm, ex);
|
||||
let rotated = d.literal(ex, "rotated");
|
||||
let rot = d.argument(rotated, "rot", ArgType::Rotation);
|
||||
@@ -179,7 +183,11 @@ fn conditionals(d: &mut Dispatcher, m: usize, ex: usize) {
|
||||
conditional(d, targets, ex);
|
||||
|
||||
let predicate = d.literal(m, "predicate");
|
||||
let p = d.argument(predicate, "predicate", ArgType::ResourceOrId("minecraft:predicate"));
|
||||
let p = d.argument(
|
||||
predicate,
|
||||
"predicate",
|
||||
ArgType::ResourceOrId("minecraft:predicate"),
|
||||
);
|
||||
conditional(d, p, ex);
|
||||
|
||||
let stopwatch = d.literal(m, "stopwatch");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Commands Horizon parses but does not run (multiplayer, profiling and
|
||||
//! server-only features): `/dialog`, `/kick`, `/posteffect`, `/fetchprofile`,
|
||||
//! `/spectate`, `/publish`, `/unpublish`, `/waypoint`, `/debug`, `/jfr` and
|
||||
//! the `/test` root.
|
||||
//! Commands of the hosted game rather than the world: `/dialog`, `/kick`,
|
||||
//! `/posteffect`, `/fetchprofile`, `/spectate`, `/publish`, `/unpublish`,
|
||||
//! `/waypoint`, `/debug`, `/jfr` and `/test`.
|
||||
use super::{
|
||||
Dispatcher,
|
||||
args::ArgType,
|
||||
@@ -126,7 +125,84 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
d.executes(l, "jfr");
|
||||
}
|
||||
|
||||
// TestCommand (game tests; only its root is modelled)
|
||||
// TestCommand (release build: no export subcommands)
|
||||
let test = d.literal(root, "test");
|
||||
d.requires(test, GAMEMASTERS);
|
||||
let tests = ArgType::ResourceSelector("minecraft:test_instance");
|
||||
let int0 = ArgType::Integer {
|
||||
min: 0,
|
||||
max: i32::MAX,
|
||||
};
|
||||
let int = ArgType::Integer {
|
||||
min: i32::MIN,
|
||||
max: i32::MAX,
|
||||
};
|
||||
// numberOfTimes [untilFailed [rotationSteps [testsPerRow]]], or only
|
||||
// the first two.
|
||||
let retry = |d: &mut Dispatcher, node: usize, id: &'static str, build_info: bool| {
|
||||
let n = d.argument(node, "numberOfTimes", int0.clone());
|
||||
d.executes(n, id);
|
||||
let u = d.argument(n, "untilFailed", ArgType::Bool);
|
||||
d.executes(u, id);
|
||||
if build_info {
|
||||
let r = d.argument(u, "rotationSteps", int.clone());
|
||||
d.executes(r, id);
|
||||
let t = d.argument(r, "testsPerRow", int.clone());
|
||||
d.executes(t, id);
|
||||
}
|
||||
};
|
||||
let run = d.literal(test, "run");
|
||||
let t = d.argument(run, "tests", tests.clone());
|
||||
d.executes(t, "test.run");
|
||||
retry(d, t, "test.run", true);
|
||||
let multiple = d.literal(test, "runmultiple");
|
||||
let t = d.argument(multiple, "tests", tests.clone());
|
||||
d.executes(t, "test.run");
|
||||
let a = d.argument(t, "amount", int.clone());
|
||||
d.executes(a, "test.run");
|
||||
for name in ["runthese", "runclosest", "runthat"] {
|
||||
let l = d.literal(test, name);
|
||||
d.executes(l, "test.run.found");
|
||||
retry(d, l, "test.run.found", false);
|
||||
}
|
||||
let failed = d.literal(test, "runfailed");
|
||||
d.executes(failed, "test.run.found");
|
||||
let only = d.argument(failed, "onlyRequiredTests", ArgType::Bool);
|
||||
d.executes(only, "test.run.found");
|
||||
retry(d, only, "test.run.found", true);
|
||||
retry(d, failed, "test.run.found", true);
|
||||
let verify = d.literal(test, "verify");
|
||||
let t = d.argument(verify, "tests", tests.clone());
|
||||
d.executes(t, "test.verify");
|
||||
let locate = d.literal(test, "locate");
|
||||
let t = d.argument(locate, "tests", tests);
|
||||
d.executes(t, "test.locate");
|
||||
for name in [
|
||||
"resetclosest",
|
||||
"resetthese",
|
||||
"resetthat",
|
||||
"clearthat",
|
||||
"clearthese",
|
||||
] {
|
||||
let l = d.literal(test, name);
|
||||
d.executes(l, "test.clear");
|
||||
}
|
||||
let all = d.literal(test, "clearall");
|
||||
d.executes(all, "test.clear");
|
||||
let r = d.argument(all, "radius", int.clone());
|
||||
d.executes(r, "test.clear");
|
||||
let stop = d.literal(test, "stop");
|
||||
d.executes(stop, "test.stop");
|
||||
let pos = d.literal(test, "pos");
|
||||
d.executes(pos, "test.pos");
|
||||
let var = d.argument(pos, "var", ArgType::Word);
|
||||
d.executes(var, "test.pos");
|
||||
let create = d.literal(test, "create");
|
||||
let id = d.argument(create, "id", ArgType::Id("none"));
|
||||
d.executes(id, "test.create");
|
||||
let w = d.argument(id, "width", int.clone());
|
||||
d.executes(w, "test.create");
|
||||
let h = d.argument(w, "height", int.clone());
|
||||
let depth = d.argument(h, "depth", int);
|
||||
d.executes(depth, "test.create");
|
||||
}
|
||||
@@ -169,7 +169,11 @@ fn item(d: &mut Dispatcher, root: usize) {
|
||||
d.argument(p, "targetPos", ArgType::BlockPos)
|
||||
};
|
||||
let slot = d.argument(target, "slot", ArgType::SlotSource);
|
||||
let modifier = d.argument(slot, "modifier", ArgType::ResourceOrId("minecraft:item_modifier"));
|
||||
let modifier = d.argument(
|
||||
slot,
|
||||
"modifier",
|
||||
ArgType::ResourceOrId("minecraft:item_modifier"),
|
||||
);
|
||||
d.executes(modifier, "item.modify");
|
||||
}
|
||||
for mode in ["replace", "fill", "override"] {
|
||||
|
||||
@@ -56,19 +56,31 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
let place = d.literal(root, "place");
|
||||
d.requires(place, GAMEMASTERS);
|
||||
let feature = d.literal(place, "feature");
|
||||
let f = d.argument(feature, "feature", ArgType::ResourceOrId("minecraft:worldgen/feature"));
|
||||
let f = d.argument(
|
||||
feature,
|
||||
"feature",
|
||||
ArgType::ResourceOrId("minecraft:worldgen/feature"),
|
||||
);
|
||||
d.executes(f, "place.feature");
|
||||
let pos = d.argument(f, "pos", ArgType::BlockPos);
|
||||
d.executes(pos, "place.feature");
|
||||
let jigsaw = d.literal(place, "jigsaw");
|
||||
let pool = d.argument(jigsaw, "pool", ArgType::ResKey("minecraft:worldgen/template_pool"));
|
||||
let pool = d.argument(
|
||||
jigsaw,
|
||||
"pool",
|
||||
ArgType::ResKey("minecraft:worldgen/template_pool"),
|
||||
);
|
||||
let target = d.argument(pool, "target", ArgType::Id("none"));
|
||||
let depth = d.argument(target, "max_depth", ArgType::Integer { min: 1, max: 20 });
|
||||
d.executes(depth, "place.jigsaw");
|
||||
let position = d.argument(depth, "position", ArgType::BlockPos);
|
||||
d.executes(position, "place.jigsaw");
|
||||
let structure = d.literal(place, "structure");
|
||||
let st = d.argument(structure, "structure", ArgType::ResKey("minecraft:worldgen/structure"));
|
||||
let st = d.argument(
|
||||
structure,
|
||||
"structure",
|
||||
ArgType::ResKey("minecraft:worldgen/structure"),
|
||||
);
|
||||
d.executes(st, "place.structure");
|
||||
let pos = d.argument(st, "pos", ArgType::BlockPos);
|
||||
d.executes(pos, "place.structure");
|
||||
@@ -89,7 +101,10 @@ pub fn register(d: &mut Dispatcher, root: usize) {
|
||||
let mirror = d.argument(
|
||||
rotation,
|
||||
"mirror",
|
||||
ArgType::StrEnum(&["none", "left_right", "front_back"], "argument.enum.invalid"),
|
||||
ArgType::StrEnum(
|
||||
&["none", "left_right", "front_back"],
|
||||
"argument.enum.invalid",
|
||||
),
|
||||
);
|
||||
d.executes(mirror, "place");
|
||||
let integrity = d.argument(mirror, "integrity", ArgType::Float { min: 0.0, max: 1.0 });
|
||||
@@ -160,7 +175,14 @@ fn rotate_swing(d: &mut Dispatcher, root: usize) {
|
||||
for hand in ["mainhand", "offhand"] {
|
||||
let h = d.literal(targets, hand);
|
||||
d.executes(h, "swing");
|
||||
let animation = d.argument(h, "animation", ArgType::StrEnum(&["none", "whack", "stab"], "argument.swing_animation.invalid"));
|
||||
let animation = d.argument(
|
||||
h,
|
||||
"animation",
|
||||
ArgType::StrEnum(
|
||||
&["none", "whack", "stab"],
|
||||
"argument.swing_animation.invalid",
|
||||
),
|
||||
);
|
||||
d.executes(animation, "swing");
|
||||
let duration = d.argument(animation, "duration", ArgType::Time { min: 1 });
|
||||
d.executes(duration, "swing");
|
||||
@@ -285,7 +307,11 @@ fn fillbiome(d: &mut Dispatcher, root: usize) {
|
||||
let biome = d.argument(to, "biome", ArgType::Res("minecraft:worldgen/biome"));
|
||||
d.executes(biome, "fillbiome");
|
||||
let replace = d.literal(biome, "replace");
|
||||
let filter = d.argument(replace, "filter", ArgType::ResOrTag("minecraft:worldgen/biome"));
|
||||
let filter = d.argument(
|
||||
replace,
|
||||
"filter",
|
||||
ArgType::ResOrTag("minecraft:worldgen/biome"),
|
||||
);
|
||||
d.executes(filter, "fillbiome");
|
||||
}
|
||||
|
||||
@@ -357,7 +383,11 @@ fn loot(d: &mut Dispatcher, root: usize) {
|
||||
// Sources.
|
||||
for t in targets {
|
||||
let fish = d.literal(t, "fish");
|
||||
let table = d.argument(fish, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
|
||||
let table = d.argument(
|
||||
fish,
|
||||
"loot_table",
|
||||
ArgType::ResourceOrId("minecraft:loot_table"),
|
||||
);
|
||||
let pos = d.argument(table, "pos", ArgType::BlockPos);
|
||||
d.executes(pos, "loot.fish");
|
||||
for hand in ["mainhand", "offhand"] {
|
||||
@@ -367,7 +397,11 @@ fn loot(d: &mut Dispatcher, root: usize) {
|
||||
let tool = d.argument(pos, "tool", ArgType::Item);
|
||||
d.executes(tool, "loot.fish");
|
||||
let l = d.literal(t, "loot");
|
||||
let lt = d.argument(l, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
|
||||
let lt = d.argument(
|
||||
l,
|
||||
"loot_table",
|
||||
ArgType::ResourceOrId("minecraft:loot_table"),
|
||||
);
|
||||
d.executes(lt, "loot.loot");
|
||||
let kill = d.literal(t, "kill");
|
||||
let target = d.argument(kill, "target", ENTITY);
|
||||
|
||||
@@ -322,13 +322,21 @@ pub fn build_with(dev: bool) -> Dispatcher {
|
||||
let locate = d.literal(root, "locate");
|
||||
d.requires(locate, GAMEMASTERS);
|
||||
let s = d.literal(locate, "structure");
|
||||
let a = d.argument(s, "structure", ArgType::ResOrTagKey("minecraft:worldgen/structure"));
|
||||
let a = d.argument(
|
||||
s,
|
||||
"structure",
|
||||
ArgType::ResOrTagKey("minecraft:worldgen/structure"),
|
||||
);
|
||||
d.executes(a, "locate.structure");
|
||||
let b = d.literal(locate, "biome");
|
||||
let a = d.argument(b, "biome", ArgType::ResOrTag("minecraft:worldgen/biome"));
|
||||
d.executes(a, "locate.biome");
|
||||
let p = d.literal(locate, "poi");
|
||||
let a = d.argument(p, "poi", ArgType::ResOrTag("minecraft:point_of_interest_type"));
|
||||
let a = d.argument(
|
||||
p,
|
||||
"poi",
|
||||
ArgType::ResOrTag("minecraft:point_of_interest_type"),
|
||||
);
|
||||
d.executes(a, "locate.poi");
|
||||
|
||||
if dev {
|
||||
@@ -395,6 +403,7 @@ pub fn build_with(dev: bool) -> Dispatcher {
|
||||
super::tree_misc::register(&mut d, root);
|
||||
super::tree_world::register(&mut d, root);
|
||||
super::tree_extra::register(&mut d, root);
|
||||
apply_vanilla_order(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
@@ -498,6 +507,42 @@ pub fn game_modes() -> [&'static str; 4] {
|
||||
GameMode::ALL.map(GameMode::name)
|
||||
}
|
||||
/// The shared vanilla tree.
|
||||
/// Reorder node children as vanilla registers them (Brigadier keeps
|
||||
/// children in registration order; smart usage lists them that way).
|
||||
fn apply_vanilla_order(d: &mut Dispatcher) {
|
||||
for (path, order) in super::vanilla_order::ORDER {
|
||||
let mut node = Some(0usize);
|
||||
for name in path.split(' ').filter(|n| !n.is_empty()) {
|
||||
node = node.and_then(|n| {
|
||||
d.nodes[n]
|
||||
.children
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&c| d.nodes[c].name() == name)
|
||||
});
|
||||
}
|
||||
let Some(n) = node else { continue };
|
||||
let mut children = d.nodes[n].children.clone();
|
||||
let rank = |c: usize| {
|
||||
order
|
||||
.iter()
|
||||
.position(|o| *o == d.nodes[c].name())
|
||||
.unwrap_or(order.len())
|
||||
};
|
||||
children.sort_by_key(|&c| rank(c));
|
||||
d.nodes[n].children = children;
|
||||
}
|
||||
}
|
||||
/// The tree the game runs: a release integrated server's, or with
|
||||
/// `HORIZON_DEV_COMMANDS` set the development one
|
||||
/// (SharedConstants.DEBUG_DEV_COMMANDS).
|
||||
pub fn game_dispatcher() -> &'static Dispatcher {
|
||||
if std::env::var_os("HORIZON_DEV_COMMANDS").is_some() {
|
||||
dispatcher()
|
||||
} else {
|
||||
release_dispatcher()
|
||||
}
|
||||
}
|
||||
pub fn dispatcher() -> &'static Dispatcher {
|
||||
static TREE: std::sync::OnceLock<Dispatcher> = std::sync::OnceLock::new();
|
||||
TREE.get_or_init(build)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! The order of node children in vanilla's command tree, for the nodes
|
||||
//! where Horizon's tree registers them differently (path of node names
|
||||
//! from the root, children in vanilla's order). Generated by
|
||||
//! tools/generate_command_order.py; do not edit by hand.
|
||||
|
||||
pub const ORDER: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"",
|
||||
&[
|
||||
"advancement",
|
||||
"attribute",
|
||||
"execute",
|
||||
"bossbar",
|
||||
"clear",
|
||||
"clone",
|
||||
"compute",
|
||||
"damage",
|
||||
"data",
|
||||
"datapack",
|
||||
"debug",
|
||||
"defaultgamemode",
|
||||
"dialog",
|
||||
"difficulty",
|
||||
"effect",
|
||||
"me",
|
||||
"enchant",
|
||||
"experience",
|
||||
"xp",
|
||||
"fill",
|
||||
"fillbiome",
|
||||
"forceload",
|
||||
"function",
|
||||
"gamemode",
|
||||
"gamerule",
|
||||
"give",
|
||||
"help",
|
||||
"item",
|
||||
"kick",
|
||||
"kill",
|
||||
"list",
|
||||
"locate",
|
||||
"loot",
|
||||
"msg",
|
||||
"tell",
|
||||
"w",
|
||||
"swing",
|
||||
"particle",
|
||||
"place",
|
||||
"playsound",
|
||||
"posteffect",
|
||||
"random",
|
||||
"reload",
|
||||
"recipe",
|
||||
"fetchprofile",
|
||||
"return",
|
||||
"ride",
|
||||
"rotate",
|
||||
"say",
|
||||
"schedule",
|
||||
"scoreboard",
|
||||
"seed",
|
||||
"version",
|
||||
"setblock",
|
||||
"spawnpoint",
|
||||
"setworldspawn",
|
||||
"spectate",
|
||||
"spreadplayers",
|
||||
"stopsound",
|
||||
"stopwatch",
|
||||
"summon",
|
||||
"tag",
|
||||
"team",
|
||||
"teammsg",
|
||||
"tm",
|
||||
"teleport",
|
||||
"tp",
|
||||
"tellraw",
|
||||
"test",
|
||||
"tick",
|
||||
"time",
|
||||
"title",
|
||||
"trigger",
|
||||
"waypoint",
|
||||
"weather",
|
||||
"worldborder",
|
||||
"jfr",
|
||||
"publish",
|
||||
"unpublish",
|
||||
],
|
||||
),
|
||||
(
|
||||
"execute",
|
||||
&[
|
||||
"run",
|
||||
"if",
|
||||
"unless",
|
||||
"as",
|
||||
"at",
|
||||
"store",
|
||||
"positioned",
|
||||
"rotated",
|
||||
"facing",
|
||||
"align",
|
||||
"anchored",
|
||||
"in",
|
||||
"summon",
|
||||
"on",
|
||||
],
|
||||
),
|
||||
(
|
||||
"execute if",
|
||||
&[
|
||||
"block",
|
||||
"biome",
|
||||
"loaded",
|
||||
"dimension",
|
||||
"score",
|
||||
"blocks",
|
||||
"entity",
|
||||
"predicate",
|
||||
"function",
|
||||
"stopwatch",
|
||||
"data",
|
||||
"items",
|
||||
"slots",
|
||||
],
|
||||
),
|
||||
(
|
||||
"execute unless",
|
||||
&[
|
||||
"block",
|
||||
"biome",
|
||||
"loaded",
|
||||
"dimension",
|
||||
"score",
|
||||
"blocks",
|
||||
"entity",
|
||||
"predicate",
|
||||
"function",
|
||||
"stopwatch",
|
||||
"data",
|
||||
"items",
|
||||
"slots",
|
||||
],
|
||||
),
|
||||
("execute facing", &["entity", "pos"]),
|
||||
("datapack", &["enable", "disable", "list", "create"]),
|
||||
(
|
||||
"datapack enable name",
|
||||
&["after", "before", "last", "first"],
|
||||
),
|
||||
("item", &["replace", "fill", "override", "modify"]),
|
||||
("item replace entity target slots", &["from", "with"]),
|
||||
("item fill entity target slots", &["from", "with"]),
|
||||
("item override entity target slots", &["from", "with"]),
|
||||
(
|
||||
"loot replace entity entities slot",
|
||||
&["fish", "loot", "kill", "mine", "count"],
|
||||
),
|
||||
(
|
||||
"loot replace entity entities slot fish loot_table pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot replace entity entities slot mine pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot replace entity entities slot count fish loot_table pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot replace entity entities slot count mine pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot replace block targetPos slot",
|
||||
&["fish", "loot", "kill", "mine", "count"],
|
||||
),
|
||||
(
|
||||
"loot replace block targetPos slot fish loot_table pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot replace block targetPos slot mine pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot replace block targetPos slot count fish loot_table pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot replace block targetPos slot count mine pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot insert targetPos fish loot_table pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot insert targetPos mine pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot give players fish loot_table pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot give players mine pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot spawn targetPos fish loot_table pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"loot spawn targetPos mine pos",
|
||||
&["tool", "mainhand", "offhand"],
|
||||
),
|
||||
(
|
||||
"scoreboard players",
|
||||
&[
|
||||
"list",
|
||||
"set",
|
||||
"get",
|
||||
"add",
|
||||
"remove",
|
||||
"reset",
|
||||
"enable",
|
||||
"display",
|
||||
"operation",
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -152,9 +152,34 @@ impl Parser<'_> {
|
||||
Err("unterminated string".into())
|
||||
}
|
||||
}
|
||||
/// The style fields of a component object.
|
||||
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
let mut style = Style::default();
|
||||
if let Some(c) = o.get("color").and_then(Value::as_str) {
|
||||
style.color = named_color(c).or_else(|| {
|
||||
c.strip_prefix('#')
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
style.bold = flag("bold");
|
||||
style.italic = flag("italic");
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
/// are resolved by `resolve` (ComponentUtils.updateForEntity).
|
||||
pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
|
||||
to_text_with(v, resolve, &mut |_| None)
|
||||
}
|
||||
/// `to_text` with a hook resolving other contents (`nbt`) from the object.
|
||||
pub fn to_text_with(
|
||||
v: &Value,
|
||||
resolve: &mut dyn FnMut(&str) -> Text,
|
||||
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
|
||||
) -> Text {
|
||||
match v {
|
||||
Value::String(s) => Text::literal(s.clone()),
|
||||
Value::Number(n) => Text::literal(n.to_string()),
|
||||
@@ -164,32 +189,27 @@ pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
|
||||
let Some((first, rest)) = list.split_first() else {
|
||||
return Text::empty();
|
||||
};
|
||||
let mut base = to_text(first, resolve);
|
||||
let style = base
|
||||
.spans
|
||||
.first()
|
||||
.map(|s| s.style.clone())
|
||||
.unwrap_or_default();
|
||||
let mut base = to_text_with(first, resolve, contents);
|
||||
// The siblings take the first component's own style, even when
|
||||
// its text is empty.
|
||||
let style = match first {
|
||||
Value::Object(o) => object_style(o),
|
||||
_ => base
|
||||
.spans
|
||||
.first()
|
||||
.map(|s| s.style.clone())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
for sibling in rest {
|
||||
base.push(to_text(sibling, resolve).with_style(&style));
|
||||
base.push(to_text_with(sibling, resolve, contents).with_style(&style));
|
||||
}
|
||||
base
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let mut style = Style::default();
|
||||
if let Some(c) = o.get("color").and_then(Value::as_str) {
|
||||
style.color = named_color(c).or_else(|| {
|
||||
c.strip_prefix('#')
|
||||
.and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
});
|
||||
}
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
style.bold = flag("bold");
|
||||
style.italic = flag("italic");
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
let content = if let Some(t) = o.get("text") {
|
||||
let style = object_style(o);
|
||||
let content = if let Some(t) = contents(o) {
|
||||
t
|
||||
} else if let Some(t) = o.get("text") {
|
||||
match t {
|
||||
Value::String(s) => Text::literal(s.clone()),
|
||||
other => Text::literal(other.to_string()),
|
||||
@@ -198,7 +218,11 @@ pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
|
||||
let args: Vec<Text> = o
|
||||
.get("with")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().map(|x| to_text(x, resolve)).collect())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|x| to_text_with(x, resolve, contents))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let translated = Text::translate(key, &args);
|
||||
match o.get("fallback").and_then(Value::as_str) {
|
||||
@@ -219,7 +243,7 @@ pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
out.push(to_text(extra, resolve).with_style(&style));
|
||||
out.push(to_text_with(extra, resolve, contents).with_style(&style));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ pub struct Crafter {
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
impl Crafter {
|
||||
/// `slotCanBeDisabled`: only empty slots.
|
||||
@@ -94,7 +97,7 @@ impl Crafter {
|
||||
.count() as i32
|
||||
}
|
||||
/// `canPlaceItem`: a hopper fills the smallest stacks first.
|
||||
pub fn can_place(&self, slot: usize, stack: &ItemStack, max_stack: u32) -> bool {
|
||||
pub fn can_place(&self, slot: usize, _stack: &ItemStack, max_stack: u32) -> bool {
|
||||
if slot >= 9 || self.disabled[slot] {
|
||||
return false;
|
||||
}
|
||||
@@ -109,7 +112,7 @@ impl Crafter {
|
||||
&& match &self.items[i] {
|
||||
None => true,
|
||||
Some(other) => {
|
||||
other.count < current.count && other.same_item_same_components(stack)
|
||||
other.count < current.count && other.same_item_same_components(current)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -184,7 +187,12 @@ impl BlockEntity {
|
||||
return Some(Self::Sculk(Box::new(sculk)));
|
||||
}
|
||||
if name == "minecraft:crafter" {
|
||||
return Some(Self::Crafter(Crafter::default()));
|
||||
// CrafterBlock.newBlockEntity copies the placed state's flag: the
|
||||
// first tick sees it as a change from untriggered.
|
||||
return Some(Self::Crafter(Crafter {
|
||||
state_triggered: Some(false),
|
||||
..Crafter::default()
|
||||
}));
|
||||
}
|
||||
if name.ends_with("_banner") {
|
||||
return Some(Self::Banner(Banner::default()));
|
||||
@@ -200,6 +208,7 @@ impl BlockEntity {
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
"minecraft:hopper" => 5,
|
||||
"minecraft:dispenser" | "minecraft:dropper" => 9,
|
||||
n if n.ends_with("shulker_box") => 27,
|
||||
@@ -211,6 +220,7 @@ impl BlockEntity {
|
||||
pub fn id_for_block(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"minecraft:chest" => "minecraft:chest",
|
||||
n if n.ends_with("copper_chest") => "minecraft:chest",
|
||||
"minecraft:trapped_chest" => "minecraft:trapped_chest",
|
||||
"minecraft:barrel" => "minecraft:barrel",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
|
||||
@@ -5,12 +5,61 @@
|
||||
//! that depend on a later frame (screenshots, benchmarks) arrive when ready.
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
os::unix::net::{UnixListener, UnixStream},
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
};
|
||||
|
||||
#[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};
|
||||
|
||||
fn unavailable() -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"the control socket needs Unix domain sockets (Linux or macOS)",
|
||||
)
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
impl io::Read for UnixStream {
|
||||
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
impl io::Write for UnixStream {
|
||||
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
|
||||
Err(unavailable())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One command line from a client, with the channel for its JSON reply.
|
||||
pub struct Request {
|
||||
pub line: String,
|
||||
|
||||
@@ -24,17 +24,26 @@ const FIREWORK_COLORS: [u32; 16] = [
|
||||
11_250_603, 2_651_799, 8_073_150, 2_437_522, 5_320_730, 3_887_386, 11_743_532, 1_973_019,
|
||||
];
|
||||
|
||||
/// A set of accepted items (expanded from an item, list or tag).
|
||||
/// A set of accepted items (expanded from an item, list or tag), and the
|
||||
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Ingredient(pub HashSet<String>);
|
||||
pub struct Ingredient(pub HashSet<String>, pub Vec<String>);
|
||||
impl Ingredient {
|
||||
/// The items of a list, in order.
|
||||
pub fn of(items: Vec<String>) -> Self {
|
||||
Self(items.iter().cloned().collect(), items)
|
||||
}
|
||||
fn parse(v: &Value, items: &Items) -> Option<Self> {
|
||||
let mut set = HashSet::new();
|
||||
let mut list: Vec<String> = Vec::new();
|
||||
let mut add = |s: &str| {
|
||||
if let Some(tag) = s.strip_prefix('#') {
|
||||
set.extend(items.item_tags.get(tag).into_iter().flatten().cloned());
|
||||
} else {
|
||||
set.insert(s.to_owned());
|
||||
let more: Vec<String> = match s.strip_prefix('#') {
|
||||
Some(tag) => items.item_tag_order.get(tag).cloned().unwrap_or_default(),
|
||||
None => vec![s.to_owned()],
|
||||
};
|
||||
for i in more {
|
||||
if !list.contains(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
};
|
||||
match v {
|
||||
@@ -50,12 +59,45 @@ impl Ingredient {
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
(!set.is_empty()).then_some(Self(set))
|
||||
(!list.is_empty()).then(|| Self::of(list))
|
||||
}
|
||||
pub fn test(&self, stack: &ItemStack) -> bool {
|
||||
self.0.contains(&*stack.item)
|
||||
}
|
||||
}
|
||||
/// `PlacementInfo`: the ingredients to find and, per recipe slot, the index
|
||||
/// of its ingredient (`slotsToIngredientIndex`, -1 for an empty slot). Empty
|
||||
/// when the recipe is impossible to place.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Placement {
|
||||
pub ingredients: Vec<Ingredient>,
|
||||
pub slots: Vec<i32>,
|
||||
/// A `ShapedRecipe`'s width and height (the others fill the grid).
|
||||
pub shaped: Option<(usize, usize)>,
|
||||
}
|
||||
impl Placement {
|
||||
/// `PlacementInfo.createFromOptionals` (and `create` with no empty slots).
|
||||
pub fn of(cells: Vec<Option<Ingredient>>, shaped: Option<(usize, usize)>) -> Self {
|
||||
let mut out = Self {
|
||||
shaped,
|
||||
..Self::default()
|
||||
};
|
||||
for cell in cells {
|
||||
match cell {
|
||||
Some(i) if i.0.is_empty() => return Self::default(),
|
||||
Some(i) => {
|
||||
out.slots.push(out.ingredients.len() as i32);
|
||||
out.ingredients.push(i);
|
||||
}
|
||||
None => out.slots.push(-1),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn impossible(&self) -> bool {
|
||||
self.slots.is_empty()
|
||||
}
|
||||
}
|
||||
/// A recipe result: `ItemStackTemplate` (`id`, `count`, `components`), with the
|
||||
/// id optional for the transmuting recipes that keep the input's item.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -1319,56 +1361,114 @@ impl Recipes {
|
||||
map_scale,
|
||||
};
|
||||
for (name, recipe) in &self.crafting {
|
||||
let result = match recipe {
|
||||
Recipe::Special(special) => {
|
||||
if !special.matches(&ctx, &input) {
|
||||
continue;
|
||||
}
|
||||
special.assemble(&ctx, &input)
|
||||
}
|
||||
Recipe::Shaped {
|
||||
width,
|
||||
height,
|
||||
cells,
|
||||
result,
|
||||
} => {
|
||||
if *width != input.width
|
||||
|| *height != input.height
|
||||
|| cells.iter().flatten().count() != input.count()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let matches = |mirror: bool| {
|
||||
(0..*height).all(|y| {
|
||||
(0..*width).all(|x| {
|
||||
let rx = if mirror { width - 1 - x } else { x };
|
||||
match (&cells[y * width + rx], input.get(x, y)) {
|
||||
(None, None) => true,
|
||||
(Some(i), Some(s)) => i.test(s),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
if !(matches(false) || matches(true)) {
|
||||
continue;
|
||||
}
|
||||
Some(result.create())
|
||||
}
|
||||
Recipe::Shapeless {
|
||||
ingredients,
|
||||
result,
|
||||
} => {
|
||||
if !matches_all(ingredients, &input.stacks()) {
|
||||
continue;
|
||||
}
|
||||
Some(result.create())
|
||||
}
|
||||
};
|
||||
return result.map(|r| (name.as_str(), r));
|
||||
if let Some(result) = Self::assemble_if_matches(&ctx, &input, recipe) {
|
||||
return result.map(|r| (name.as_str(), r));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `Recipe.matches` then `assemble` for one recipe: None when it does
|
||||
/// not match, Some(None) when it matches with nothing to make.
|
||||
fn assemble_if_matches(
|
||||
ctx: &Context,
|
||||
input: &Input,
|
||||
recipe: &Recipe,
|
||||
) -> Option<Option<ItemStack>> {
|
||||
match recipe {
|
||||
Recipe::Special(special) => special
|
||||
.matches(ctx, input)
|
||||
.then(|| special.assemble(ctx, input)),
|
||||
Recipe::Shaped {
|
||||
width,
|
||||
height,
|
||||
cells,
|
||||
result,
|
||||
} => {
|
||||
if *width != input.width
|
||||
|| *height != input.height
|
||||
|| cells.iter().flatten().count() != input.count()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let matches = |mirror: bool| {
|
||||
(0..*height).all(|y| {
|
||||
(0..*width).all(|x| {
|
||||
let rx = if mirror { width - 1 - x } else { x };
|
||||
match (&cells[y * width + rx], input.get(x, y)) {
|
||||
(None, None) => true,
|
||||
(Some(i), Some(s)) => i.test(s),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
(matches(false) || matches(true)).then(|| Some(result.create()))
|
||||
}
|
||||
Recipe::Shapeless {
|
||||
ingredients,
|
||||
result,
|
||||
} => matches_all(ingredients, &input.stacks()).then(|| Some(result.create())),
|
||||
}
|
||||
}
|
||||
/// `recipe.matches(craftSlots.asCraftInput())` for one recipe by id
|
||||
/// (`minecraft:name`).
|
||||
pub fn recipe_matches(&self, id: &str, grid: &[Option<ItemStack>], size: usize) -> bool {
|
||||
let name = id.trim_start_matches("minecraft:");
|
||||
let Some((_, recipe)) = self.crafting.iter().find(|(n, _)| n == name) else {
|
||||
return false;
|
||||
};
|
||||
let Some(input) = Input::of(grid, size) else {
|
||||
return false;
|
||||
};
|
||||
let ctx = Context {
|
||||
recipes: self,
|
||||
map_scale: &|_| None,
|
||||
};
|
||||
Self::assemble_if_matches(&ctx, &input, recipe).is_some()
|
||||
}
|
||||
/// `Recipe.placementInfo` of a crafting or cooking recipe by id; None for
|
||||
/// an unknown id, an empty placement when it is impossible to place.
|
||||
pub fn placement(&self, id: &str) -> Option<Placement> {
|
||||
let name = id.trim_start_matches("minecraft:");
|
||||
if let Some(c) = self.cooking.iter().find(|c| c.id == name) {
|
||||
return Some(Placement::of(vec![Some(c.ingredient.clone())], None));
|
||||
}
|
||||
let (_, recipe) = self.crafting.iter().find(|(n, _)| n == name)?;
|
||||
Some(match recipe {
|
||||
Recipe::Shaped {
|
||||
width,
|
||||
height,
|
||||
cells,
|
||||
..
|
||||
} => Placement::of(cells.clone(), Some((*width, *height))),
|
||||
Recipe::Shapeless { ingredients, .. } => {
|
||||
Placement::of(ingredients.iter().cloned().map(Some).collect(), None)
|
||||
}
|
||||
Recipe::Special(Special::Transmute {
|
||||
input,
|
||||
material,
|
||||
max,
|
||||
..
|
||||
}) => {
|
||||
let mut list = vec![Some(input.clone())];
|
||||
list.extend(std::iter::repeat_n(Some(material.clone()), *max));
|
||||
Placement::of(list, None)
|
||||
}
|
||||
Recipe::Special(Special::Dye { target, dye, .. }) => {
|
||||
Placement::of(vec![Some(target.clone()), Some(dye.clone())], None)
|
||||
}
|
||||
Recipe::Special(Special::Imbue {
|
||||
source, material, ..
|
||||
}) => Placement::of(
|
||||
(0..9)
|
||||
.map(|i| Some(if i == 4 { source } else { material }.clone()))
|
||||
.collect(),
|
||||
None,
|
||||
),
|
||||
// CustomRecipe.placementInfo: NOT_PLACEABLE.
|
||||
Recipe::Special(_) => Placement::default(),
|
||||
})
|
||||
}
|
||||
/// `Recipe.getRemainingItems` placed on the `size`x`size` grid: an item's
|
||||
/// crafting remainder (buckets, bottles), the patterned banner a duplicate
|
||||
/// keeps and the written book a clone keeps.
|
||||
@@ -1434,7 +1534,7 @@ mod tests {
|
||||
use crate::banner::BannerLayer;
|
||||
|
||||
fn ingredient(items: &[&str]) -> Ingredient {
|
||||
Ingredient(items.iter().map(|s| (*s).to_owned()).collect())
|
||||
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
|
||||
}
|
||||
fn patterned(item: &str) -> ItemStack {
|
||||
let mut s = ItemStack::new(item, 1);
|
||||
|
||||
@@ -67,6 +67,15 @@ fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl DataPack {
|
||||
/// The built-in `vanilla` pack: the game's own data directory.
|
||||
pub fn builtin(root: &Path) -> Self {
|
||||
DataPack {
|
||||
id: "vanilla".into(),
|
||||
source: Source::Dir(root.to_path_buf()),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The packs in `<world>/datapacks`, sorted by name.
|
||||
pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
|
||||
|
||||
@@ -135,6 +135,16 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// What an omen's `applyEffectTick` decided.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum OmenTick {
|
||||
/// It returned true: the effect counts down as usual.
|
||||
Continue,
|
||||
/// It returned false: the effect goes.
|
||||
Remove,
|
||||
/// It added this effect, then returned false.
|
||||
Add(EffectInstance),
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PlayerEffects {
|
||||
pub list: Vec<EffectInstance>,
|
||||
@@ -160,6 +170,71 @@ impl PlayerEffects {
|
||||
pub fn clear(&mut self) {
|
||||
self.list.clear();
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
|
||||
/// (on its last tick). Returns the actions to apply, in order, and
|
||||
/// whether Raid Omen was newly added.
|
||||
///
|
||||
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
|
||||
/// iterated: vanilla's iterator then throws a
|
||||
/// `ConcurrentModificationException`, which `tickEffects` swallows. Bad
|
||||
/// Omen stays (not counted down) and the effects after it are not
|
||||
/// ticked; the next tick refreshes Raid Omen and drops Bad Omen.
|
||||
pub fn tick_with(
|
||||
&mut self,
|
||||
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
|
||||
) -> (Vec<EffectAction>, bool) {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
let mut actions = Vec::new();
|
||||
let mut added = false;
|
||||
let mut i = 0;
|
||||
while i < self.list.len() {
|
||||
let e = &self.list[i];
|
||||
if !(e.infinite() || e.duration > 0) {
|
||||
self.list.remove(i);
|
||||
continue;
|
||||
}
|
||||
let t = if e.infinite() {
|
||||
self.tick_count
|
||||
} else {
|
||||
e.duration
|
||||
};
|
||||
if let Some(a) = periodic(e.id, t, e.amplifier) {
|
||||
actions.push(a);
|
||||
}
|
||||
let answer = match e.id {
|
||||
"bad_omen" => omen(e),
|
||||
"raid_omen" if t == 1 => omen(e),
|
||||
_ => OmenTick::Continue,
|
||||
};
|
||||
match answer {
|
||||
OmenTick::Continue => {}
|
||||
OmenTick::Remove => {
|
||||
self.list.remove(i);
|
||||
continue;
|
||||
}
|
||||
OmenTick::Add(effect) => {
|
||||
if let Some(existing) = self.list.iter_mut().find(|x| x.id == effect.id) {
|
||||
existing.update(&effect);
|
||||
self.list.remove(i);
|
||||
continue;
|
||||
}
|
||||
self.list.push(effect);
|
||||
added = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let e = &mut self.list[i];
|
||||
e.tick_down();
|
||||
e.downgrade();
|
||||
if e.infinite() || e.duration > 0 {
|
||||
i += 1;
|
||||
} else {
|
||||
self.list.remove(i);
|
||||
}
|
||||
}
|
||||
(actions, added)
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part): actions to apply, in order.
|
||||
pub fn tick(&mut self) -> Vec<EffectAction> {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
@@ -325,6 +400,45 @@ impl StatusFlags {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bad_omen_in_a_village_becomes_raid_omen_over_two_ticks() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("bad_omen", 100, 1));
|
||||
fx.add(EffectInstance::new("speed", 50, 0));
|
||||
let mut convert = |e: &EffectInstance| match e.id {
|
||||
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
|
||||
_ => OmenTick::Continue,
|
||||
};
|
||||
// The first tick adds Raid Omen and stops there (the iterator's
|
||||
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
|
||||
let (_, added) = fx.tick_with(&mut convert);
|
||||
assert!(added);
|
||||
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 50);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
|
||||
// The second refreshes Raid Omen and drops Bad Omen.
|
||||
let (_, added) = fx.tick_with(&mut convert);
|
||||
assert!(!added);
|
||||
assert!(fx.get("bad_omen").is_none());
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
|
||||
}
|
||||
#[test]
|
||||
fn raid_omen_acts_on_its_last_tick() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("raid_omen", 2, 0));
|
||||
let mut seen = Vec::new();
|
||||
let mut watch = |e: &EffectInstance| {
|
||||
seen.push(e.duration);
|
||||
OmenTick::Remove
|
||||
};
|
||||
fx.tick_with(&mut watch);
|
||||
assert!(fx.get("raid_omen").is_some());
|
||||
fx.tick_with(&mut watch);
|
||||
assert!(fx.get("raid_omen").is_none());
|
||||
assert_eq!(seen, vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_keeps_stronger_and_hides_longer() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
|
||||
@@ -11,6 +11,10 @@ struct Globals {
|
||||
sun: vec4<f32>,
|
||||
// x: sky fog end, y: cloud fog end
|
||||
fog_ends: vec4<f32>,
|
||||
// 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)
|
||||
time: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -27,11 +31,14 @@ struct Out {
|
||||
@location(4) @interpolate(flat) overlay: u32,
|
||||
// Overlay 6: the sprite's v origin and height, for v repeated inside it.
|
||||
@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>,
|
||||
}
|
||||
@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 {
|
||||
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);
|
||||
out.uv = uv;
|
||||
let l = vec2(f32(light.y), f32(light.x));
|
||||
let lm = textureSampleLevel(lightmap, lightmap_sampler,
|
||||
@@ -52,6 +59,41 @@ struct Out {
|
||||
if overlay == 4u { out.spherical = length(position); }
|
||||
return out;
|
||||
}
|
||||
// rendertype_end_portal.fsh: the layer colours.
|
||||
const PORTAL_COLORS = array<vec3<f32>, 16>(
|
||||
vec3(0.022087, 0.098399, 0.110818),
|
||||
vec3(0.011892, 0.095924, 0.089485),
|
||||
vec3(0.027636, 0.101689, 0.100326),
|
||||
vec3(0.046564, 0.109883, 0.114838),
|
||||
vec3(0.064901, 0.117696, 0.097189),
|
||||
vec3(0.063761, 0.086895, 0.123646),
|
||||
vec3(0.084817, 0.111994, 0.166380),
|
||||
vec3(0.097489, 0.154120, 0.091064),
|
||||
vec3(0.106152, 0.131144, 0.195191),
|
||||
vec3(0.097721, 0.110188, 0.187229),
|
||||
vec3(0.133516, 0.138278, 0.148582),
|
||||
vec3(0.070006, 0.243332, 0.235792),
|
||||
vec3(0.196766, 0.142899, 0.214696),
|
||||
vec3(0.047281, 0.315338, 0.321970),
|
||||
vec3(0.204675, 0.390010, 0.302066),
|
||||
vec3(0.080955, 0.314821, 0.661491),
|
||||
);
|
||||
// textureProj on a repeating texture that lives in the atlas at `rect`.
|
||||
fn portal_sample(rect: vec4<f32>, p: vec4<f32>) -> vec3<f32> {
|
||||
let st = fract(p.xy / p.w);
|
||||
return textureSampleLevel(atlas, atlas_sampler, rect.xy + st * rect.zw, 0.0).rgb;
|
||||
}
|
||||
// texProj0 * end_portal_layer(layer): the row vector through mat4(scale * rotate),
|
||||
// translate and SCALE_TRANSLATE in turn.
|
||||
fn portal_layer(p: vec4<f32>, layer: f32) -> vec4<f32> {
|
||||
let a = radians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
|
||||
let c = cos(a);
|
||||
let s = sin(a);
|
||||
let k = (4.5 - layer / 4.0) * 2.0;
|
||||
var v = vec4(k * (c * p.x - s * p.y), k * (s * p.x + c * p.y), p.z, p.w);
|
||||
v = vec4(v.x + 17.0 / layer * v.w, v.y + (2.0 + layer / 1.5) * (globals.time.x * 1.5) * v.w, v.z, v.w);
|
||||
return vec4(0.5 * v.x + 0.25 * v.w, 0.5 * v.y + 0.25 * v.w, v.z, v.w);
|
||||
}
|
||||
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d <= start { return 0.0; }
|
||||
if d >= end { return 1.0; }
|
||||
@@ -61,7 +103,16 @@ fn linear_fog(d: f32, start: f32, end: f32) -> 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 == 4u {
|
||||
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.
|
||||
let layers = select(15, 16, in.overlay == 10u);
|
||||
var c = portal_sample(globals.end_portal_sprites[0], in.proj) * PORTAL_COLORS[0];
|
||||
for (var i = 0; i < layers; i++) {
|
||||
c += portal_sample(globals.end_portal_sprites[1], portal_layer(in.proj, f32(i + 1))) * PORTAL_COLORS[i];
|
||||
}
|
||||
color = vec4(c, 1.0);
|
||||
} else if in.overlay == 4u {
|
||||
// clouds.fsh: alpha fades out towards the cloud fog end.
|
||||
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
|
||||
}
|
||||
|
||||
@@ -253,17 +253,37 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
} else {
|
||||
map.insert("equipment".into(), Tag::compound(equipment));
|
||||
}
|
||||
let attr = |id: &str, v: f64| {
|
||||
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
|
||||
let modelled = [
|
||||
("minecraft:max_health", f64::from(m.attrs.max_health)),
|
||||
("minecraft:movement_speed", m.attrs.speed),
|
||||
("minecraft:jump_strength", m.extra.vector[0]),
|
||||
];
|
||||
// AttributeMap.pack writes the instances that exist: the ones the
|
||||
// entity was loaded with, or all three randomized by finalizeSpawn.
|
||||
let attributes = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let id = text(a.get("id"));
|
||||
let value = modelled.iter().find(|(m, _)| Some(*m) == id.as_deref());
|
||||
match (a, value) {
|
||||
(Tag::Compound(c), Some((_, v))) => {
|
||||
let mut c = c.clone();
|
||||
c.insert("base".into(), Tag::Double(v.to_bits()));
|
||||
Tag::Compound(c)
|
||||
}
|
||||
_ => a.clone(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
_ => modelled
|
||||
.iter()
|
||||
.map(|(id, v)| {
|
||||
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
map.insert(
|
||||
"attributes".into(),
|
||||
Tag::List(vec![
|
||||
attr("minecraft:max_health", f64::from(m.attrs.max_health)),
|
||||
attr("minecraft:movement_speed", m.attrs.speed),
|
||||
attr("minecraft:jump_strength", m.extra.vector[0]),
|
||||
]),
|
||||
);
|
||||
map.insert("attributes".into(), Tag::List(attributes));
|
||||
}
|
||||
"minecraft:pig" | "minecraft:strider" => {
|
||||
let mut equipment = match map.get(&"equipment".into()) {
|
||||
@@ -387,7 +407,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
// Skeletons are spawned holding a bow (finalizeSpawn); a mob loaded
|
||||
// from NBT keeps only its saved equipment.
|
||||
"minecraft:skeleton" | "minecraft:stray" | "minecraft:bogged" if raw.is_none() => {
|
||||
"minecraft:skeleton" | "minecraft:stray" | "minecraft:bogged"
|
||||
if raw.is_none() && m.extra.main_hand.as_deref() == Some("minecraft:bow") =>
|
||||
{
|
||||
map.entry("equipment".into()).or_insert_with(|| {
|
||||
Tag::compound([(
|
||||
"mainhand",
|
||||
@@ -410,6 +432,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// CopperGolem.addAdditionalSaveData.
|
||||
"minecraft:copper_golem" => {
|
||||
map.insert(
|
||||
"next_weather_age".into(),
|
||||
Tag::Long(m.extra.vector[0] as i64),
|
||||
);
|
||||
map.insert(
|
||||
"weather_state".into(),
|
||||
string(crate::mobs::copper_golem::WEATHER[m.extra.variant.min(3) as usize]),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
@@ -717,6 +750,21 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
if m.species() == crate::mobs::Species::Sniffer {
|
||||
crate::mobs::sniffer::from_nbt(&mut m, tag);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::CopperGolem {
|
||||
// CopperGolem.readAdditionalSaveData: -1 (unset) when missing.
|
||||
m.extra.vector[0] = match tag.get("next_weather_age") {
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
_ => -1.0,
|
||||
};
|
||||
let weather = match tag.get("weather_state") {
|
||||
Some(Tag::String(s)) => s.to_string_lossy(),
|
||||
_ => String::new(),
|
||||
};
|
||||
m.extra.variant = crate::mobs::copper_golem::WEATHER
|
||||
.iter()
|
||||
.position(|w| *w == weather)
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
m.extra.leash_pending = crate::mobs::leash::pending_from_nbt(tag.get("leash"));
|
||||
if m.species() == crate::mobs::Species::Warden {
|
||||
// The brain starts afresh: a minute before it digs back down.
|
||||
@@ -926,7 +974,8 @@ pub fn falling_block_to_nbt(
|
||||
state: Tag,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
time: u32,
|
||||
time: i32,
|
||||
drop_item: bool,
|
||||
uuid: [i32; 4],
|
||||
) -> Tag {
|
||||
let base = Base {
|
||||
@@ -941,8 +990,8 @@ pub fn falling_block_to_nbt(
|
||||
let mut tag = base.tag(None);
|
||||
for (key, value) in [
|
||||
("BlockState", state),
|
||||
("Time", Tag::Int(time as i32)),
|
||||
("DropItem", Tag::Byte(1)),
|
||||
("Time", Tag::Int(time)),
|
||||
("DropItem", Tag::Byte(i8::from(drop_item))),
|
||||
("HurtEntities", Tag::Byte(0)),
|
||||
("CancelDrop", Tag::Byte(0)),
|
||||
("FallHurtAmount", Tag::Float(0)),
|
||||
@@ -953,15 +1002,23 @@ pub fn falling_block_to_nbt(
|
||||
tag
|
||||
}
|
||||
/// (block state tag, position, motion, time)
|
||||
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, u32)> {
|
||||
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32)> {
|
||||
Some((
|
||||
tag.get("BlockState")?.clone(),
|
||||
position(tag)?,
|
||||
motion(tag),
|
||||
number(tag.get("Time")).unwrap_or(0).max(0) as u32,
|
||||
number(tag.get("Time")).unwrap_or(0) as i32,
|
||||
))
|
||||
}
|
||||
|
||||
/// `NoGravity` and `DropItem` (true when absent) of a saved falling block.
|
||||
pub fn falling_block_flags(tag: &Tag) -> (bool, bool) {
|
||||
(
|
||||
flag(tag, "NoGravity"),
|
||||
number(tag.get("DropItem")).is_none_or(|v| v != 0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
|
||||
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let (lo, hi) = p.bounds();
|
||||
@@ -1089,6 +1146,25 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
{
|
||||
put(&mut tag, "Health", Tag::Float(20f32.to_bits()));
|
||||
}
|
||||
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
for (key, value) in [
|
||||
("HurtTime", Tag::Short(0)),
|
||||
("DeathTime", Tag::Short(0)),
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
("current_impulse_context_reset_grace_time", Tag::Int(0)),
|
||||
("attributes", Tag::List(Vec::new())),
|
||||
(
|
||||
"Brain",
|
||||
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
|
||||
),
|
||||
("DisabledSlots", Tag::Int(0)),
|
||||
("Pose", Tag::Compound(Compound::new())),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
@@ -1107,22 +1183,19 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.on_ground = flag(tag, "OnGround");
|
||||
Some(a)
|
||||
}
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_raft`).
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
|
||||
/// chest boats keep their slots in `Items` (`ContainerHelper.saveAllItems`).
|
||||
pub fn boat_to_nbt(
|
||||
wood: &str,
|
||||
id: &str,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
yaw: f32,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
items: Option<&[Option<crate::items::ItemStack>]>,
|
||||
) -> Tag {
|
||||
let id = if wood == "bamboo" {
|
||||
"minecraft:bamboo_raft".to_owned()
|
||||
} else {
|
||||
format!("minecraft:{wood}_boat")
|
||||
};
|
||||
Base {
|
||||
id: &id,
|
||||
let mut tag = Base {
|
||||
id,
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
@@ -1130,7 +1203,11 @@ pub fn boat_to_nbt(
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw)
|
||||
.tag(raw);
|
||||
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
|
||||
map.insert("Items".into(), crate::save::encode_items(items));
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// A vehicle's base entity tag (boats and minecarts).
|
||||
pub fn vehicle_to_nbt(
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Iteration order of the Java hash collections vanilla's scoreboard keeps
|
||||
//! its names in: fastutil's `Object2ObjectOpenHashMap<String, _>` (open
|
||||
//! addressing with linear probing, backward-shift deletion and rehashing at
|
||||
//! the load factor) and `java.util.HashSet<String>` (chained buckets split on
|
||||
//! resize). Commands list, and `scoreboard.dat` saves, in these orders.
|
||||
|
||||
/// The keys of a fastutil open hash map in their table slots.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OpenOrder {
|
||||
keys: Vec<Option<String>>,
|
||||
size: usize,
|
||||
min_n: usize,
|
||||
load: f32,
|
||||
}
|
||||
impl Default for OpenOrder {
|
||||
/// `new Object2ObjectOpenHashMap<>()`: 16 expected entries, load 0.75.
|
||||
fn default() -> Self {
|
||||
Self::new(16, 0.75)
|
||||
}
|
||||
}
|
||||
/// `String.hashCode`.
|
||||
pub fn java_hash(s: &str) -> i32 {
|
||||
s.encode_utf16()
|
||||
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c)))
|
||||
}
|
||||
/// `HashCommon.mix`.
|
||||
fn mix(x: i32) -> usize {
|
||||
let h = (x as u32).wrapping_mul(0x9E37_79B9);
|
||||
(h ^ (h >> 16)) as usize
|
||||
}
|
||||
/// `HashCommon.arraySize`.
|
||||
fn array_size(expected: usize, f: f32) -> usize {
|
||||
let wanted = (expected as f64 / f64::from(f)).ceil() as usize;
|
||||
wanted.max(2).next_power_of_two()
|
||||
}
|
||||
/// `HashCommon.maxFill`.
|
||||
fn max_fill(n: usize, f: f32) -> usize {
|
||||
((n as f64 * f64::from(f)).ceil() as usize).min(n - 1)
|
||||
}
|
||||
impl OpenOrder {
|
||||
pub fn new(expected: usize, load: f32) -> Self {
|
||||
let n = array_size(expected, load);
|
||||
Self {
|
||||
keys: vec![None; n],
|
||||
size: 0,
|
||||
min_n: n,
|
||||
load,
|
||||
}
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.size
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.size == 0
|
||||
}
|
||||
fn slot(&self, key: &str) -> usize {
|
||||
mix(java_hash(key)) & (self.keys.len() - 1)
|
||||
}
|
||||
pub fn contains(&self, key: &str) -> bool {
|
||||
let mask = self.keys.len() - 1;
|
||||
let mut pos = self.slot(key);
|
||||
while let Some(k) = &self.keys[pos] {
|
||||
if k == key {
|
||||
return true;
|
||||
}
|
||||
pos = (pos + 1) & mask;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `put`: false when the key was already there.
|
||||
pub fn insert(&mut self, key: &str) -> bool {
|
||||
let mask = self.keys.len() - 1;
|
||||
let mut pos = self.slot(key);
|
||||
while let Some(k) = &self.keys[pos] {
|
||||
if k == key {
|
||||
return false;
|
||||
}
|
||||
pos = (pos + 1) & mask;
|
||||
}
|
||||
self.keys[pos] = Some(key.to_owned());
|
||||
// `if (size++ >= maxFill) rehash(arraySize(size + 1, f))`.
|
||||
let old = self.size;
|
||||
self.size += 1;
|
||||
if old >= max_fill(self.keys.len(), self.load) {
|
||||
self.rehash(array_size(self.size + 1, self.load));
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `remove`: false when the key was not there.
|
||||
pub fn remove(&mut self, key: &str) -> bool {
|
||||
let mask = self.keys.len() - 1;
|
||||
let mut pos = self.slot(key);
|
||||
loop {
|
||||
match &self.keys[pos] {
|
||||
None => return false,
|
||||
Some(k) if k == key => break,
|
||||
Some(_) => pos = (pos + 1) & mask,
|
||||
}
|
||||
}
|
||||
self.size -= 1;
|
||||
self.shift_keys(pos);
|
||||
let n = self.keys.len();
|
||||
if n > self.min_n && self.size < max_fill(n, self.load) / 4 && n > 16 {
|
||||
self.rehash(n / 2);
|
||||
}
|
||||
true
|
||||
}
|
||||
fn shift_keys(&mut self, mut pos: usize) {
|
||||
let mask = self.keys.len() - 1;
|
||||
loop {
|
||||
let last = pos;
|
||||
pos = (last + 1) & mask;
|
||||
loop {
|
||||
let Some(curr) = self.keys[pos].clone() else {
|
||||
self.keys[last] = None;
|
||||
return;
|
||||
};
|
||||
let slot = self.slot(&curr);
|
||||
let stop = if last <= pos {
|
||||
last >= slot || slot > pos
|
||||
} else {
|
||||
last >= slot && slot > pos
|
||||
};
|
||||
if stop {
|
||||
self.keys[last] = Some(curr);
|
||||
break;
|
||||
}
|
||||
pos = (pos + 1) & mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn rehash(&mut self, new_n: usize) {
|
||||
let old = std::mem::replace(&mut self.keys, vec![None; new_n]);
|
||||
let mask = new_n - 1;
|
||||
// `for (int j = realSize(); j-- != 0;) { while (key[--i] == null); ... }`
|
||||
for key in old.into_iter().rev().flatten() {
|
||||
let mut pos = mix(java_hash(&key)) & mask;
|
||||
while self.keys[pos].is_some() {
|
||||
pos = (pos + 1) & mask;
|
||||
}
|
||||
self.keys[pos] = Some(key);
|
||||
}
|
||||
}
|
||||
/// `iterator()` and `forEach`: from the last slot down.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &str> {
|
||||
self.keys.iter().rev().flatten().map(String::as_str)
|
||||
}
|
||||
/// `spliterator()` (what `stream()` uses): from the first slot up.
|
||||
pub fn stream(&self) -> impl Iterator<Item = &str> {
|
||||
self.keys.iter().flatten().map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of a `java.util.HashSet<String>` in their buckets.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct HashSetOrder {
|
||||
/// Empty until the first insertion (`HashMap.resize` on first `put`).
|
||||
table: Vec<Vec<String>>,
|
||||
size: usize,
|
||||
}
|
||||
/// `HashMap.hash`.
|
||||
fn spread(s: &str) -> usize {
|
||||
let h = java_hash(s) as u32;
|
||||
(h ^ (h >> 16)) as usize
|
||||
}
|
||||
impl HashSetOrder {
|
||||
pub fn len(&self) -> usize {
|
||||
self.size
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.size == 0
|
||||
}
|
||||
pub fn contains(&self, key: &str) -> bool {
|
||||
if self.table.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let i = spread(key) & (self.table.len() - 1);
|
||||
self.table[i].iter().any(|k| k == key)
|
||||
}
|
||||
pub fn insert(&mut self, key: &str) -> bool {
|
||||
if self.table.is_empty() {
|
||||
self.table = vec![Vec::new(); 16];
|
||||
}
|
||||
let i = spread(key) & (self.table.len() - 1);
|
||||
if self.table[i].iter().any(|k| k == key) {
|
||||
return false;
|
||||
}
|
||||
self.table[i].push(key.to_owned());
|
||||
self.size += 1;
|
||||
if self.size > self.table.len() * 3 / 4 {
|
||||
self.resize();
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Doubles the table; each bucket splits into its low and high halves in order.
|
||||
fn resize(&mut self) {
|
||||
let old = std::mem::take(&mut self.table);
|
||||
let n = old.len() * 2;
|
||||
self.table = vec![Vec::new(); n];
|
||||
for bucket in old {
|
||||
for key in bucket {
|
||||
let i = spread(&key) & (n - 1);
|
||||
self.table[i].push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn remove(&mut self, key: &str) -> bool {
|
||||
if self.table.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let i = spread(key) & (self.table.len() - 1);
|
||||
let Some(at) = self.table[i].iter().position(|k| k == key) else {
|
||||
return false;
|
||||
};
|
||||
self.table[i].remove(at);
|
||||
self.size -= 1;
|
||||
true
|
||||
}
|
||||
pub fn iter(&self) -> impl Iterator<Item = &String> {
|
||||
self.table.iter().flatten()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_and_order() {
|
||||
assert_eq!(java_hash("harness"), {
|
||||
"harness"
|
||||
.chars()
|
||||
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(c as i32))
|
||||
});
|
||||
let mut o = OpenOrder::new(16, 0.5);
|
||||
for k in ["harness", "harness_h"] {
|
||||
o.insert(k);
|
||||
}
|
||||
assert_eq!(o.iter().count(), 2);
|
||||
let mut asc: Vec<&str> = o.stream().collect();
|
||||
asc.reverse();
|
||||
assert_eq!(asc, o.iter().collect::<Vec<_>>());
|
||||
o.remove("harness");
|
||||
assert_eq!(o.iter().collect::<Vec<_>>(), vec!["harness_h"]);
|
||||
for i in 0..40 {
|
||||
o.insert(&format!("k{i}"));
|
||||
}
|
||||
assert_eq!(o.iter().count(), 41);
|
||||
for i in 0..40 {
|
||||
o.remove(&format!("k{i}"));
|
||||
}
|
||||
assert_eq!(o.iter().collect::<Vec<_>>(), vec!["harness_h"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_set_buckets() {
|
||||
let mut s = HashSetOrder::default();
|
||||
for i in 0..30 {
|
||||
assert!(s.insert(&format!("p{i}")));
|
||||
}
|
||||
assert!(!s.insert("p3"));
|
||||
assert_eq!(s.len(), 30);
|
||||
assert!(s.remove("p3") && !s.contains("p3"));
|
||||
// "Aa" and "BB" share a hash: insertion order within the bucket.
|
||||
let mut t = HashSetOrder::default();
|
||||
t.insert("BB");
|
||||
t.insert("Aa");
|
||||
assert_eq!(t.iter().collect::<Vec<_>>(), vec!["BB", "Aa"]);
|
||||
}
|
||||
}
|
||||
@@ -451,6 +451,72 @@ impl Font {
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `StringSplitter.splitLines`: each line and whether it continues a
|
||||
/// wrapped one. A line breaks at a `\n`, or once a character overflows
|
||||
/// `max_width` (never the first visible one) at the last space so far, or
|
||||
/// before that character; the `\n` or space at the break is dropped.
|
||||
pub fn split_lines(&self, t: &Text, max_width: f32) -> Vec<(Text, bool)> {
|
||||
let max_width = max_width.max(1.0);
|
||||
let chars: Vec<(char, &Style)> = t.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut out = Vec::new();
|
||||
let (mut start, mut wrapped, mut force_new_line) = (0, false, false);
|
||||
loop {
|
||||
// LineBreakFinder over what is left.
|
||||
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
|
||||
let mut split = None;
|
||||
for (i, &(c, style)) in chars.iter().enumerate().skip(start) {
|
||||
if c == '\n' {
|
||||
split = Some(i);
|
||||
break;
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
let advance = self.advance(c, style);
|
||||
width += advance;
|
||||
if !had_visible || width <= max_width {
|
||||
had_visible |= advance != 0.0;
|
||||
} else {
|
||||
split = Some(last_space.unwrap_or(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(at) = split else { break };
|
||||
let new_line = chars[at].0 == '\n';
|
||||
let skip = usize::from(new_line || chars[at].0 == ' ');
|
||||
force_new_line = new_line;
|
||||
out.push((t.slice(start, at), wrapped));
|
||||
wrapped = !new_line;
|
||||
start = at + skip;
|
||||
}
|
||||
if start < n {
|
||||
out.push((t.slice(start, n), wrapped));
|
||||
} else if force_new_line {
|
||||
out.push((Text::empty(), false));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `ComponentRenderUtils.wrapComponents`: the split lines, wrapped ones
|
||||
/// indented by an unstyled space; one empty line for empty text.
|
||||
pub fn wrap_components(&self, t: &Text, max_width: i32) -> Vec<Text> {
|
||||
let lines: Vec<Text> = self
|
||||
.split_lines(t, max_width as f32)
|
||||
.into_iter()
|
||||
.map(|(line, wrapped)| {
|
||||
if wrapped {
|
||||
Text::literal(" ").append(line)
|
||||
} else {
|
||||
line
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if lines.is_empty() {
|
||||
vec![Text::empty()]
|
||||
} else {
|
||||
lines
|
||||
}
|
||||
}
|
||||
/// StringSplitter.splitLines: wrap at the last space that fits (or
|
||||
/// mid-word when none does); `\n` always breaks. Styles are kept.
|
||||
pub fn split(&self, t: &Text, max_width: f32) -> Vec<Text> {
|
||||
|
||||
+911
-1093
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,7 @@ use horizon_client::{
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
collections::{BTreeMap, VecDeque},
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
@@ -46,7 +46,6 @@ pub(super) struct AdvState {
|
||||
pub(super) tree: Arc<Tree>,
|
||||
pub(super) progress: Progress,
|
||||
loaded: bool,
|
||||
pub(super) recipes: BTreeSet<String>,
|
||||
pub(super) toasts: VecDeque<Toast>,
|
||||
/// Awards skip the toast (`/advancement grant ... everything`).
|
||||
pub(super) quiet: bool,
|
||||
@@ -168,6 +167,12 @@ fn now_text() -> String {
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ItemPredicate.test` (the `item_filter` of a filtered item modifier).
|
||||
pub(super) fn loot_item_filter(&self, predicate: &Value, s: &ItemStack) -> bool {
|
||||
adv::item(predicate, &item_view(s), &View(self))
|
||||
}
|
||||
}
|
||||
pub(super) fn item_view(s: &ItemStack) -> ItemView {
|
||||
ItemView {
|
||||
id: s.item.to_string(),
|
||||
@@ -214,13 +219,9 @@ impl Game<'_> {
|
||||
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
|
||||
.map(|v| Progress::from_json(&v))
|
||||
.unwrap_or_default();
|
||||
// Unlocked recipes come from the done recipe advancements' rewards.
|
||||
// The known recipes are the player's own (`recipeBook`), not the
|
||||
// rewards of done advancements: /recipe take sticks.
|
||||
let tree = self.adv.tree.clone();
|
||||
for a in &tree.list {
|
||||
if progress.done(a) {
|
||||
self.adv.recipes.extend(a.rewards.recipes.iter().cloned());
|
||||
}
|
||||
}
|
||||
self.adv.progress = progress;
|
||||
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
|
||||
}
|
||||
@@ -375,6 +376,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// After `ServerRecipeBook.addRecipes`: the toast and the
|
||||
/// `recipe_unlocked` trigger of each recipe now known.
|
||||
pub(super) fn recipe_unlocked(&mut self, unlocked: &[String]) {
|
||||
self.recipe_toast(unlocked);
|
||||
for r in unlocked {
|
||||
let r = r.clone();
|
||||
self.trigger("minecraft:recipe_unlocked", move |c, _| {
|
||||
c["recipes"].as_str() == Some(r.as_str())
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `PlayerAdvancements.award` once done: rewards, toast, announcement.
|
||||
fn award(&mut self, index: usize) {
|
||||
let tree = self.adv.tree.clone();
|
||||
@@ -383,12 +395,18 @@ impl Game<'_> {
|
||||
if a.rewards.experience > 0 {
|
||||
self.experience.give(a.rewards.experience);
|
||||
}
|
||||
let mut unlocked = Vec::new();
|
||||
for r in &a.rewards.recipes {
|
||||
if self.adv.recipes.insert(r.clone()) {
|
||||
unlocked.push(r.clone());
|
||||
}
|
||||
}
|
||||
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
|
||||
let rewards: Vec<String> = a
|
||||
.rewards
|
||||
.recipes
|
||||
.iter()
|
||||
.filter(|r| self.res.stations.recipe_index.contains(r))
|
||||
.cloned()
|
||||
.collect();
|
||||
let (_, unlocked) = self
|
||||
.level
|
||||
.recipe_book
|
||||
.add_recipes(&self.res.stations.recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = self
|
||||
.res
|
||||
@@ -425,12 +443,7 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
}
|
||||
self.recipe_toast(&unlocked);
|
||||
for r in unlocked {
|
||||
self.trigger("minecraft:recipe_unlocked", |c, _| {
|
||||
c["recipes"].as_str() == Some(r.as_str())
|
||||
});
|
||||
}
|
||||
self.recipe_unlocked(&unlocked);
|
||||
}
|
||||
/// `InventoryChangeTrigger`, checked when the inventory changes.
|
||||
pub(super) fn check_inventory_trigger(&mut self) {
|
||||
@@ -505,6 +518,17 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.check_inventory_trigger();
|
||||
// ServerPlayer.tick: PlayerTrigger.TICK every tick.
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:tick", |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &ctx, w)
|
||||
});
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:location", |c, w| {
|
||||
|
||||
@@ -415,6 +415,8 @@ impl Game<'_> {
|
||||
pose[5] = deg(rot[2]);
|
||||
}
|
||||
}
|
||||
// Item layers pose from the hidden arms too (`visible` only skips drawing).
|
||||
let posed = poses.clone();
|
||||
let mut hide = |name: &str| {
|
||||
if poses.has(name) {
|
||||
let pose = poses.get(name);
|
||||
@@ -446,6 +448,7 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet.
|
||||
for (slot, layer) in [
|
||||
(5usize, "helmet"),
|
||||
@@ -562,3 +565,120 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ItemInHandLayer` and `CustomHeadLayer` of an armour stand: the hand
|
||||
/// items in the third-person contexts, and a non-armour head item on the
|
||||
/// head (`translateToHead`: down a quarter, turned, 0.625 scale).
|
||||
fn armor_stand_items(
|
||||
&self,
|
||||
a: &ArmorStand,
|
||||
model: &Part,
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::mobs::render::part_matrix;
|
||||
let owner = super::item_draw::owner_at(
|
||||
[a.position.x, a.position.y, a.position.z],
|
||||
a.yaw,
|
||||
"minecraft:armor_stand",
|
||||
);
|
||||
// `useBabyOffset`: small stands use the baby hand offsets.
|
||||
let (ox, oy, oz) = if a.small {
|
||||
(0.0, 1.0, -4.5)
|
||||
} else {
|
||||
(1.0, 2.0, -10.0)
|
||||
};
|
||||
for (slot, left, context) in [
|
||||
(0usize, false, DisplayContext::ThirdPersonRightHand),
|
||||
(1, true, DisplayContext::ThirdPersonLeftHand),
|
||||
] {
|
||||
let Some(stack) = &a.equipment[slot] else {
|
||||
continue;
|
||||
};
|
||||
let arm = if left { "left_arm" } else { "right_arm" };
|
||||
let Some(hand) = part_matrix(model, base, poses, arm, 0.0) else {
|
||||
continue;
|
||||
};
|
||||
let pose = hand
|
||||
* glam::Mat4::from_rotation_x((-90.0f32).to_radians())
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(
|
||||
if left { -ox } else { ox } / 16.0,
|
||||
oy / 16.0,
|
||||
oz / 16.0,
|
||||
));
|
||||
let seed = a.id as i32 + if left { 1 } else { 2 };
|
||||
let state = self.resolve_item(stack, context, Some(&owner), seed);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
}
|
||||
// `CustomHeadLayer`: a worn skull draws the skull model.
|
||||
if let Some(stack) = &a.equipment[5]
|
||||
&& let Some(kind) = skull_kind(&stack.item)
|
||||
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
|
||||
{
|
||||
let pose = head * glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
|
||||
let special = horizon_assets::item_model::SpecialModel {
|
||||
kind: if kind == "player" {
|
||||
horizon_assets::item_model::SpecialKind::PlayerHead
|
||||
} else {
|
||||
horizon_assets::item_model::SpecialKind::Head {
|
||||
kind: kind.to_owned(),
|
||||
texture: None,
|
||||
animation: 0.0,
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut sink = horizon_client::item_render::WorldSink {
|
||||
out,
|
||||
light,
|
||||
lights: Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
)),
|
||||
};
|
||||
self.res.item_renderer.submit_special(
|
||||
&special,
|
||||
&serde_json::Value::Null,
|
||||
false,
|
||||
pose,
|
||||
&self.res.atlas.sprites,
|
||||
&self.item_host(),
|
||||
&mut sink,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A head item that is not worn armour.
|
||||
if let Some(stack) = &a.equipment[5]
|
||||
&& self
|
||||
.res
|
||||
.items
|
||||
.get(&stack.item)
|
||||
.is_none_or(|i| i.equip_asset.is_none())
|
||||
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
|
||||
{
|
||||
let pose = head
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625));
|
||||
// `updateForLiving`: seeded by the entity id plus HEAD's ordinal.
|
||||
let state =
|
||||
self.resolve_item(stack, DisplayContext::Head, Some(&owner), a.id as i32 + 5);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractSkullBlock.getType` of a skull item.
|
||||
fn skull_kind(item: &str) -> Option<&'static str> {
|
||||
Some(match item {
|
||||
"minecraft:skeleton_skull" => "skeleton",
|
||||
"minecraft:wither_skeleton_skull" => "wither_skeleton",
|
||||
"minecraft:zombie_head" => "zombie",
|
||||
"minecraft:creeper_head" => "creeper",
|
||||
"minecraft:dragon_head" => "dragon",
|
||||
"minecraft:piglin_head" => "piglin",
|
||||
"minecraft:player_head" => "player",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -2,14 +2,10 @@
|
||||
//! banner item to the block when placed and back to the dropped item when
|
||||
//! broken (`copy_components` in the banner loot tables).
|
||||
use super::Game;
|
||||
use glam::{EulerRot, Mat4, Quat, Vec3};
|
||||
use horizon_client::{
|
||||
banner::{self, BannerLayer},
|
||||
block_entity_render::{Paint, draw_part},
|
||||
containers::{Banner, BlockEntity},
|
||||
hud::{HudBatch, rgba},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -23,7 +19,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A placed banner takes the item's layers and name.
|
||||
pub(super) fn fill_placed_banner(&mut self, pos: [i32; 3], stack: &ItemStack) {
|
||||
self.pending_banner.remove(&pos);
|
||||
self.pending.banner.remove(&pos);
|
||||
if !stack.item.ends_with("_banner") {
|
||||
return;
|
||||
}
|
||||
@@ -34,175 +30,6 @@ impl Game<'_> {
|
||||
b.name = stack.custom_name().map(str::to_owned);
|
||||
}
|
||||
}
|
||||
/// A banner in a slot: `item/template_banner`'s `gui` transform on the
|
||||
/// banner model (pole, bar and flag with every layer), flat-lit and
|
||||
/// depth-sorted. False when the stack is not a banner.
|
||||
pub(super) fn draw_banner_icon(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> bool {
|
||||
let Some(base) = banner::banner_color(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
|
||||
return true;
|
||||
};
|
||||
let banners = &self.res.stations.banners;
|
||||
let sprites = &self.res.atlas.sprites;
|
||||
// display.gui, then the item's special transformation (a banner is
|
||||
// modelled upside down and 2/3 size around the block centre).
|
||||
let m = Mat4::from_translation(Vec3::new(0.0, -3.25 / 16.0, 0.0))
|
||||
* Mat4::from_quat(Quat::from_euler(
|
||||
EulerRot::XYZ,
|
||||
30f32.to_radians(),
|
||||
20f32.to_radians(),
|
||||
0.0,
|
||||
))
|
||||
* Mat4::from_scale(Vec3::splat(0.5325))
|
||||
* Mat4::from_translation(Vec3::new(0.0, -0.5, 0.0))
|
||||
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
|
||||
let wave = |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "flag" {
|
||||
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
|
||||
}
|
||||
};
|
||||
let none = |_: &str, _: &mut [f32; 9]| {};
|
||||
let mut passes: Vec<(&str, u32, bool, String)> = vec![
|
||||
("entity/banner/banner_base", 0xFFFFFF, true, String::new()),
|
||||
(
|
||||
"entity/banner/base",
|
||||
banner::dye_rgb(base),
|
||||
false,
|
||||
String::new(),
|
||||
),
|
||||
];
|
||||
for layer in banner::layers(stack).iter().take(16) {
|
||||
passes.push((
|
||||
"",
|
||||
banner::dye_rgb(&layer.color),
|
||||
false,
|
||||
banners.banner_texture(&layer.pattern),
|
||||
));
|
||||
}
|
||||
// (depth, pass, screen corners, uv, colour)
|
||||
let mut faces: Vec<(f32, usize, [[f32; 2]; 4], [[f32; 2]; 4], u32)> = Vec::new();
|
||||
for (index, (fixed, tint, with_pole, dynamic)) in passes.iter().enumerate() {
|
||||
let name = if fixed.is_empty() {
|
||||
dynamic.as_str()
|
||||
} else {
|
||||
fixed
|
||||
};
|
||||
let Some(sprite) = sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let paint = Paint {
|
||||
sprite,
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: (Vec3::ZERO, Vec3::ZERO),
|
||||
};
|
||||
let mut verts: Vec<Vertex> = Vec::new();
|
||||
if *with_pole {
|
||||
draw_part(main, "root", m, &none, paint, &mut verts);
|
||||
}
|
||||
draw_part(flag, "root", m, &wave, paint, &mut verts);
|
||||
for quad in verts.as_chunks::<4>().0 {
|
||||
let p = |i: usize| Vec3::from(quad[i].pos);
|
||||
let normal = (p(1) - p(0)).cross(p(2) - p(0)).normalize_or_zero();
|
||||
// Faces turned away from the viewer are culled.
|
||||
if normal.z <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let depth = (0..4).map(|i| p(i).z).sum::<f32>() / 4.0;
|
||||
let shade = (0.6 + 0.4 * normal.z).min(1.0);
|
||||
let c = |s: u32| (((tint >> s) & 255) as f32 * shade) as u8;
|
||||
faces.push((
|
||||
depth,
|
||||
index,
|
||||
std::array::from_fn(|i| {
|
||||
[
|
||||
x + 8.0 + quad[i].pos[0] * 16.0,
|
||||
y + 8.0 - quad[i].pos[1] * 16.0,
|
||||
]
|
||||
}),
|
||||
std::array::from_fn(|i| quad[i].uv),
|
||||
rgba(c(16), c(8), c(0), 255),
|
||||
));
|
||||
}
|
||||
}
|
||||
faces.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
for (_, _, corners, uv, color) in faces {
|
||||
batch.quad(corners, uv, color);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A banner in the first-person hand: the standing banner model under the
|
||||
/// item's `firstperson_righthand` transform (`m`) and its special
|
||||
/// transformation. False when the stack is not a banner.
|
||||
pub(super) fn held_banner_geometry(
|
||||
&self,
|
||||
m: Mat4,
|
||||
stack: &ItemStack,
|
||||
packed: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> bool {
|
||||
let Some(base) = banner::banner_color(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
|
||||
return true;
|
||||
};
|
||||
let m = m
|
||||
* Mat4::from_translation(Vec3::new(0.5, 0.0, 0.5))
|
||||
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
|
||||
let wave = |name: &str, p: &mut [f32; 9]| {
|
||||
if name == "flag" {
|
||||
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
|
||||
}
|
||||
};
|
||||
let none = |_: &str, _: &mut [f32; 9]| {};
|
||||
let lights = horizon_client::block_entity_render::diffuse_lights(false);
|
||||
let sprites = &self.res.atlas.sprites;
|
||||
let paint = |name: &str, tint: u32| {
|
||||
sprites.get(name).map(|&sprite| Paint {
|
||||
sprite,
|
||||
tint,
|
||||
light: packed,
|
||||
lights,
|
||||
})
|
||||
};
|
||||
if let Some(p) = paint("entity/banner/banner_base", 0xFFFFFF) {
|
||||
draw_part(main, "root", m, &none, p, out);
|
||||
draw_part(flag, "root", m, &wave, p, out);
|
||||
}
|
||||
if let Some(p) = paint("entity/banner/base", banner::dye_rgb(base)) {
|
||||
draw_part(flag, "root", m, &wave, p, out);
|
||||
}
|
||||
for layer in banner::layers(stack).iter().take(16) {
|
||||
let texture = self.res.stations.banners.banner_texture(&layer.pattern);
|
||||
if let Some(p) = paint(&texture, banner::dye_rgb(&layer.color)) {
|
||||
draw_part(flag, "root", m, &wave, p, out);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The layers a banner dropped at `pos` carries.
|
||||
pub(super) fn attach_banner_layers(&mut self, pos: [i32; 3], stack: &mut ItemStack) {
|
||||
if !stack.item.ends_with("_banner") {
|
||||
return;
|
||||
}
|
||||
if let Some(Banner { layers, name }) = self.pending_banner.remove(&pos) {
|
||||
let data = stack.data_mut();
|
||||
data.banner_patterns = layers;
|
||||
if name.is_some() {
|
||||
data.custom_name = name;
|
||||
}
|
||||
stack.tidy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Texture names and tints of banner layers (`Sheets.getBannerSprite`).
|
||||
|
||||
@@ -522,9 +522,6 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The sprite's v range rides in the light bytes (entity shader overlay 6).
|
||||
let q = |v: f32| (v * 65535.0).round().clamp(0.0, 65535.0) as u32;
|
||||
let wrap = q(s.v0) | q(s.v1 - s.v0) << 16;
|
||||
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
|
||||
let scoping = self.scoping();
|
||||
for (k, e) in &self.world.block_entities {
|
||||
@@ -539,75 +536,17 @@ impl Game<'_> {
|
||||
} else {
|
||||
(distance / 96.0).max(1.0)
|
||||
};
|
||||
let o = (base - camera).as_vec3();
|
||||
let mut start = 0i32;
|
||||
for (i, &(color, height)) in b.sections.iter().enumerate() {
|
||||
let height = if i == b.sections.len() - 1 {
|
||||
2048
|
||||
} else {
|
||||
height
|
||||
};
|
||||
let end = start + height;
|
||||
let scroll = -time;
|
||||
let v_off = (scroll * 0.2 - (scroll * 0.1).floor()).rem_euclid(1.0);
|
||||
let rgb = |a: u32| {
|
||||
a << 24 | (color & 255) << 16 | (color >> 8 & 255) << 8 | (color >> 16 & 255)
|
||||
};
|
||||
// Solid beam: radius 0.2, rotated with time.
|
||||
let r = 0.2 * scale;
|
||||
let rot = glam::Mat3::from_rotation_y((time * 2.25 - 45.0).to_radians());
|
||||
let corners = [
|
||||
rot * glam::Vec3::new(0.0, 0.0, r),
|
||||
rot * glam::Vec3::new(r, 0.0, 0.0),
|
||||
rot * glam::Vec3::new(-r, 0.0, 0.0),
|
||||
rot * glam::Vec3::new(0.0, 0.0, -r),
|
||||
];
|
||||
let v2 = -1.0 + v_off;
|
||||
let v1 = height as f32 * (0.5 / r) + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], rgb(255), s, wrap);
|
||||
// Glow: radius 0.25, translucent.
|
||||
let g = 0.25 * scale;
|
||||
let corners = [
|
||||
glam::Vec3::new(-g, 0.0, -g),
|
||||
glam::Vec3::new(g, 0.0, -g),
|
||||
glam::Vec3::new(-g, 0.0, g),
|
||||
glam::Vec3::new(g, 0.0, g),
|
||||
];
|
||||
let v1 = height as f32 + v2;
|
||||
beam_part(out, o, start, end, corners, [v1, v2], rgb(32), s, wrap);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `renderPart`: four quads between the corners (wn, en, ws, es).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn beam_part(
|
||||
out: &mut Vec<Vertex>,
|
||||
o: glam::Vec3,
|
||||
start: i32,
|
||||
end: i32,
|
||||
[wn, en, ws, es]: [glam::Vec3; 4],
|
||||
[v1, v2]: [f32; 2],
|
||||
color: u32,
|
||||
s: horizon_client::hud::Rect,
|
||||
wrap: u32,
|
||||
) {
|
||||
for (a, b) in [(wn, en), (es, ws), (en, es), (ws, wn)] {
|
||||
let at = |c: glam::Vec3, y: i32| o + c + glam::Vec3::new(0.0, y as f32, 0.0);
|
||||
for (p, uv) in [
|
||||
(at(a, end), [s.u1, v1]),
|
||||
(at(a, start), [s.u1, v2]),
|
||||
(at(b, start), [s.u0, v2]),
|
||||
(at(b, end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
color,
|
||||
light: wrap,
|
||||
});
|
||||
let origin = (glam::DVec3::new(f64::from(k.0), f64::from(k.1), f64::from(k.2))
|
||||
- camera)
|
||||
.as_vec3();
|
||||
horizon_client::block_entity_render::beacon_beam(
|
||||
origin,
|
||||
&b.sections,
|
||||
time,
|
||||
scale,
|
||||
s,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! The client side of animated block entities (`horizon_client::block_entity_anim`):
|
||||
//! their client tickers (`Level.tickBlockEntities` on the client level) and
|
||||
//! the block events that drive them (`ClientboundBlockEventPacket` reaching
|
||||
//! `BlockEntity.triggerEvent`). The renderer reads the resulting state through
|
||||
//! [`Game::block_anim`] and [`Game::chest_lid`].
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
|
||||
block_entity_render::BlockAnim,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save::number,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The handle of the local player among a bell's gathered entities.
|
||||
const PLAYER: u64 = u64::MAX;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `BlockEntity.triggerEvent(a, b)` on the client copy of the block entity at `pos`.
|
||||
pub(super) fn block_entity_event(&mut self, pos: [i32; 3], a: i32, b: i32) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
let time = self.world.game_time;
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
self.be_anims
|
||||
.chests
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b);
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
self.be_anims
|
||||
.shulkers
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b);
|
||||
}
|
||||
Some(Ticker::Bell) => {
|
||||
let gathered = self.bell_gather(pos);
|
||||
self.be_anims
|
||||
.bells
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b, time, || gathered);
|
||||
}
|
||||
Some(Ticker::Gateway) => {
|
||||
let initial = self.gateway_loaded(pos);
|
||||
self.be_anims
|
||||
.gateways
|
||||
.entry(pos)
|
||||
.or_insert(initial)
|
||||
.trigger_event(a, b);
|
||||
}
|
||||
Some(Ticker::Pot) => {
|
||||
self.be_anims
|
||||
.pots
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b, time);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// A player opened (`open`) or closed the container at `key`: the opener
|
||||
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
|
||||
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
|
||||
/// on both halves of a double chest (`CompoundContainer`).
|
||||
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
// Spectators never count as openers.
|
||||
if self.spectator() {
|
||||
return;
|
||||
}
|
||||
let pos = [key.0, key.1, key.2];
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
let count = i32::from(open);
|
||||
self.block_entity_event(pos, 1, count);
|
||||
if let Some(other) = self.chest_other_half(key) {
|
||||
self.block_entity_event([other.0, other.1, other.2], 1, count);
|
||||
}
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
let shulker = self.be_anims.shulkers.entry(pos).or_default();
|
||||
let (count, _) = if open {
|
||||
shulker.start_open()
|
||||
} else {
|
||||
shulker.stop_open()
|
||||
};
|
||||
self.block_entity_event(pos, 1, count);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
|
||||
/// box meets the bell's block box inflated by 48.
|
||||
fn bell_gather(&self, pos: [i32; 3]) -> Vec<u64> {
|
||||
let area = Aabb::new(
|
||||
Vec3::new(
|
||||
f64::from(pos[0]) - 48.0,
|
||||
f64::from(pos[1]) - 48.0,
|
||||
f64::from(pos[2]) - 48.0,
|
||||
),
|
||||
Vec3::new(
|
||||
f64::from(pos[0]) + 49.0,
|
||||
f64::from(pos[1]) + 49.0,
|
||||
f64::from(pos[2]) + 49.0,
|
||||
),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
if self
|
||||
.player_view()
|
||||
.is_some_and(|p| p.bounds.intersects(area))
|
||||
{
|
||||
out.push(PLAYER);
|
||||
}
|
||||
for m in &self.mobs {
|
||||
if !m.removed && m.bounds().intersects(area) {
|
||||
out.push(u64::from(m.id));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box opens only when its lid has
|
||||
/// room to rise (`level.noCollision` of the half-open lid box).
|
||||
pub(super) fn shulker_can_open(&self, pos: [i32; 3]) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
let Some(Ticker::Shulker { facing }) = self.be_anims.ticker(state) else {
|
||||
return true;
|
||||
};
|
||||
let closed = self
|
||||
.be_anims
|
||||
.shulkers
|
||||
.get(&pos)
|
||||
.is_none_or(|s| s.status == ShulkerStatus::Closed);
|
||||
if !closed {
|
||||
return true;
|
||||
}
|
||||
let b = Shulker::lid_box(pos, facing);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
};
|
||||
no_collision_empty(
|
||||
&mut scene,
|
||||
Aabb::new(Vec3::new(b[0], b[1], b[2]), Vec3::new(b[3], b[4], b[5])),
|
||||
)
|
||||
}
|
||||
/// The client tickers of the loaded animated block entities, once per
|
||||
/// client tick (`ClientLevel.tickBlockEntities`).
|
||||
pub(super) fn tick_block_entity_anims(&mut self) {
|
||||
let Some(r) = &self.renderer else {
|
||||
return;
|
||||
};
|
||||
let present: HashMap<[i32; 3], Ticker> = r
|
||||
.block_entities()
|
||||
.filter_map(|(p, s)| Some((*p, self.be_anims.ticker(*s)?)))
|
||||
.collect();
|
||||
self.be_anims.retain_present(&present);
|
||||
let players: Vec<[f64; 3]> = match &self.player {
|
||||
Some(p) if !self.spectator() => vec![[p.position.x, p.position.y, p.position.z]],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let raiders = self
|
||||
.res
|
||||
.stations
|
||||
.enchantments
|
||||
.entity_tags
|
||||
.get("minecraft:raiders")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let frames = self
|
||||
.res
|
||||
.items
|
||||
.block_tags
|
||||
.get("minecraft:conduit_effect_block")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut positions: Vec<([i32; 3], Ticker)> = present.into_iter().collect();
|
||||
positions.sort_unstable_by_key(|(p, _)| *p);
|
||||
for (pos, ticker) in positions {
|
||||
match ticker {
|
||||
Ticker::Lid => self.be_anims.chests.entry(pos).or_default().tick(),
|
||||
Ticker::Book => {
|
||||
let anims = &mut self.be_anims;
|
||||
anims
|
||||
.books
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.tick(pos, &players, &mut anims.book_random);
|
||||
}
|
||||
Ticker::Shulker { facing } => {
|
||||
// The lid's push of entities in its way (moveCollidedEntities)
|
||||
// and the neighbour updates are left to the level.
|
||||
self.be_anims
|
||||
.shulkers
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.tick(pos, facing);
|
||||
}
|
||||
Ticker::Skull { powered } => {
|
||||
self.be_anims.skulls.entry(pos).or_default().tick(powered);
|
||||
}
|
||||
Ticker::Bell => {
|
||||
let player = self.player_view().map(|p| BellEntity {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
alive: p.alive,
|
||||
raider: false,
|
||||
});
|
||||
let mobs = &self.mobs;
|
||||
let entity = |h: u64| {
|
||||
if h == PLAYER {
|
||||
return player;
|
||||
}
|
||||
let m = mobs.iter().find(|m| u64::from(m.id) == h)?;
|
||||
Some(BellEntity {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
alive: m.alive() && !m.removed,
|
||||
raider: raiders.contains(&m.kind.id),
|
||||
})
|
||||
};
|
||||
let tick = self
|
||||
.be_anims
|
||||
.bells
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.tick(pos, entity);
|
||||
let centre = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if tick.resonate {
|
||||
// The server's copy plays the resonance for everyone.
|
||||
self.audio
|
||||
.play("minecraft:block.bell.resonate", Some(centre), 1.0, 1.0);
|
||||
}
|
||||
if let Some((particles, glow)) = tick.ended {
|
||||
for (p, color) in particles {
|
||||
// ColorParticleOption.create(ENTITY_EFFECT, color): ARGB.
|
||||
let c = color as u32;
|
||||
let channel = |shift: u32| ((c >> shift) & 255) as f32 / 255.0;
|
||||
let options = ParticleOptions {
|
||||
color: [channel(16), channel(8), channel(0), channel(24)],
|
||||
..ParticleOptions::simple(ParticleType::EntityEffect)
|
||||
};
|
||||
self.particles.add(&options, p, [0.0; 3]);
|
||||
}
|
||||
// makeRaidersGlow.
|
||||
for m in self.mobs.iter_mut() {
|
||||
if glow.contains(&u64::from(m.id)) {
|
||||
m.add_effect("minecraft:glowing", 60, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ticker::Conduit => {
|
||||
let world = &self.world;
|
||||
let facts = &self.res.facts;
|
||||
let registry = self.res.assets.registry;
|
||||
let is_water = |p: [i32; 3]| {
|
||||
facts.get(world.block(p[0], p[1], p[2])).fluid
|
||||
== horizon_client::blocks::Fluid::Water
|
||||
};
|
||||
let is_frame = |p: [i32; 3]| {
|
||||
registry
|
||||
.state(world.block(p[0], p[1], p[2]))
|
||||
.is_some_and(|s| frames.contains(&s.name))
|
||||
};
|
||||
let time = world.game_time;
|
||||
let anims = &mut self.be_anims;
|
||||
let particles = anims.conduits.entry(pos).or_default().client_tick(
|
||||
pos,
|
||||
time,
|
||||
is_water,
|
||||
is_frame,
|
||||
None,
|
||||
self.particles.random(),
|
||||
);
|
||||
let nautilus = ParticleOptions::simple(ParticleType::Nautilus);
|
||||
for (p, v) in particles {
|
||||
self.particles.add(&nautilus, p, v);
|
||||
}
|
||||
}
|
||||
Ticker::Pot => {}
|
||||
Ticker::Gateway => {
|
||||
// The server copy's portalTick runs first; its block event
|
||||
// reaches the client copy before the client ticks it.
|
||||
let initial = self.gateway_loaded(pos);
|
||||
let anims = &mut self.be_anims;
|
||||
let server = anims.gateway_servers.entry(pos).or_insert(initial);
|
||||
let triggered = server.portal_tick();
|
||||
let client = anims.gateways.entry(pos).or_insert(initial);
|
||||
if triggered {
|
||||
client.trigger_event(1, 0);
|
||||
}
|
||||
client.tick();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
|
||||
/// `pos` let the entity in (`triggerCooldown`, with its block event).
|
||||
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
|
||||
let initial = self.gateway_loaded(pos);
|
||||
let entered = self
|
||||
.be_anims
|
||||
.gateway_servers
|
||||
.entry(pos)
|
||||
.or_insert(initial)
|
||||
.enter();
|
||||
if entered {
|
||||
self.block_entity_event(pos, 1, 0);
|
||||
}
|
||||
entered
|
||||
}
|
||||
/// The End gateway at `pos` as its chunk was loaded: `Age` from the saved
|
||||
/// block entity (`getUpdateTag`), or a new gateway.
|
||||
fn gateway_loaded(&self, pos: [i32; 3]) -> EndGateway {
|
||||
let age = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(pos[0] >> 4, pos[2] >> 4))
|
||||
.and_then(|c| c.saved.as_deref())
|
||||
.and_then(|saved| {
|
||||
saved.block_entities.iter().find(|t| {
|
||||
[t.get("x"), t.get("y"), t.get("z")]
|
||||
.map(number)
|
||||
.eq(&pos.map(|v| Some(i64::from(v))))
|
||||
})
|
||||
})
|
||||
.and_then(|t| number(t.get("Age")));
|
||||
EndGateway {
|
||||
age: age.unwrap_or(0),
|
||||
teleport_cooldown: 0,
|
||||
}
|
||||
}
|
||||
/// The renderer's view of the block entity at `pos`.
|
||||
pub(super) fn block_anim(&self, pos: [i32; 3], partial: f32) -> BlockAnim {
|
||||
self.be_anims
|
||||
.block_anim(pos, self.world.game_time, partial, Default::default())
|
||||
}
|
||||
/// `ChestLidController.getOpenness(partial)` at `pos`.
|
||||
pub(super) fn chest_lid(&self, pos: [i32; 3], partial: f32) -> f32 {
|
||||
self.be_anims.lid(pos, partial)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,12 @@ pub(super) struct Boat {
|
||||
/// left, right, up, down
|
||||
pub input: [bool; 4],
|
||||
pub on_ground: bool,
|
||||
/// `ChestBoat`: a chest with 27 slots in the back seat.
|
||||
pub chest: bool,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
@@ -76,8 +81,16 @@ impl Boat {
|
||||
paddling: [false; 2],
|
||||
input: [false; 4],
|
||||
on_ground: false,
|
||||
chest: false,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A chest boat or chest raft.
|
||||
pub fn with_chest(mut self) -> Self {
|
||||
self.chest = true;
|
||||
self.items = vec![None; CHEST_SLOTS];
|
||||
self
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let (w, p) = (WIDTH / 2.0, self.position);
|
||||
Aabb::new(
|
||||
@@ -95,6 +108,30 @@ impl Boat {
|
||||
}
|
||||
}
|
||||
/// Item id -> (boat entity id, wood).
|
||||
impl Boat {
|
||||
/// The vanilla entity type id (`oak_boat`, `bamboo_chest_raft`), also
|
||||
/// the item it drops.
|
||||
pub(super) fn entity_type(&self) -> String {
|
||||
let chest = if self.chest { "chest_" } else { "" };
|
||||
if &*self.wood == "bamboo" {
|
||||
format!("minecraft:bamboo_{chest}raft")
|
||||
} else {
|
||||
format!("minecraft:{}_{chest}boat", self.wood)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The wood of a boat or chest boat item or entity type, and whether it
|
||||
/// carries a chest.
|
||||
pub(super) fn any_boat_wood(item: &str) -> Option<(&str, bool)> {
|
||||
let name = item.strip_prefix("minecraft:")?;
|
||||
if let Some(wood) = name
|
||||
.strip_suffix("_chest_boat")
|
||||
.or_else(|| name.strip_suffix("_chest_raft"))
|
||||
{
|
||||
return Some((wood, true));
|
||||
}
|
||||
boat_wood(item).map(|w| (w, false))
|
||||
}
|
||||
pub(super) fn boat_wood(item: &str) -> Option<&str> {
|
||||
let name = item.strip_prefix("minecraft:")?;
|
||||
name.strip_suffix("_boat")
|
||||
@@ -103,10 +140,20 @@ pub(super) fn boat_wood(item: &str) -> Option<&str> {
|
||||
}
|
||||
pub(super) struct BoatModels {
|
||||
pub models: HashMap<String, Part>,
|
||||
pub chest_models: HashMap<String, Part>,
|
||||
}
|
||||
impl BoatModels {
|
||||
/// Whether a boat of this wood (with or without a chest) can be drawn.
|
||||
pub fn has(&self, wood: &str, chest: bool) -> bool {
|
||||
if chest {
|
||||
self.chest_models.contains_key(wood)
|
||||
} else {
|
||||
self.models.contains_key(wood)
|
||||
}
|
||||
}
|
||||
pub fn load(root: &std::path::Path) -> Self {
|
||||
let mut models = HashMap::new();
|
||||
let mut chest_models = HashMap::new();
|
||||
if let Ok(bytes) = std::fs::read(root.join("entity-models.json"))
|
||||
&& let Ok(serde_json::Value::Object(all)) =
|
||||
serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
@@ -119,9 +166,19 @@ impl BoatModels {
|
||||
{
|
||||
models.insert(wood.to_owned(), part);
|
||||
}
|
||||
if let Some(wood) = key
|
||||
.strip_prefix("minecraft:chest_boat/")
|
||||
.and_then(|k| k.strip_suffix("#main"))
|
||||
&& let Some(part) = horizon_client::mobs::load_model(root, key)
|
||||
{
|
||||
chest_models.insert(wood.to_owned(), part);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { models }
|
||||
Self {
|
||||
models,
|
||||
chest_models,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -457,10 +514,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(wood) = boat_wood(&stack.item) else {
|
||||
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
if !self.boat_models.models.contains_key(wood) {
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return false;
|
||||
}
|
||||
let Some(eye) = self.eye_position() else {
|
||||
@@ -507,12 +564,15 @@ impl Game<'_> {
|
||||
(None, Some(w)) => w.0,
|
||||
(None, None) => return false,
|
||||
};
|
||||
let boat = Boat::new(
|
||||
let mut boat = Boat::new(
|
||||
self.next_entity + 1,
|
||||
Arc::from(wood),
|
||||
Vec3::new(hit[0], hit[1], hit[2]),
|
||||
self.yaw,
|
||||
);
|
||||
if chest {
|
||||
boat = boat.with_chest();
|
||||
}
|
||||
let bb = boat.bounds();
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let e = 1.0e-7;
|
||||
@@ -575,11 +635,24 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
if !self.before_block(d)
|
||||
|| sneaking
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
{
|
||||
if !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
key,
|
||||
horizon_client::containers::BlockEntity::Chest(b.items.clone()),
|
||||
);
|
||||
self.container = Some(key);
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
return true;
|
||||
}
|
||||
if sneaking || self.boats[i].out_of_control >= 60.0 || self.riding.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(self.boats[i].id);
|
||||
@@ -601,13 +674,13 @@ impl Game<'_> {
|
||||
let destroyed = self.creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !self.creative && self.ui.rules.bool("entity_drops") {
|
||||
let item = if &*b.wood == "bamboo" {
|
||||
"minecraft:bamboo_raft".to_owned()
|
||||
} else {
|
||||
format!("minecraft:{}_boat", b.wood)
|
||||
};
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
let item = b.entity_type();
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
item: Arc::from(item.as_str()),
|
||||
@@ -628,14 +701,20 @@ impl Game<'_> {
|
||||
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let a = alpha as f32;
|
||||
for b in &self.boats {
|
||||
let Some(model) = self.boat_models.models.get(&*b.wood) else {
|
||||
let models = if b.chest {
|
||||
&self.boat_models.chest_models
|
||||
} else {
|
||||
&self.boat_models.models
|
||||
};
|
||||
let Some(model) = models.get(&*b.wood) else {
|
||||
continue;
|
||||
};
|
||||
let folder = if b.chest { "chest_boat" } else { "boat" };
|
||||
let Some(uv) = self
|
||||
.res
|
||||
.atlas
|
||||
.sprites
|
||||
.get(&format!("entity/boat/{}", b.wood))
|
||||
.get(&format!("entity/{folder}/{}", b.wood))
|
||||
.map(|r| [r.u0, r.v0, r.u1, r.v1])
|
||||
else {
|
||||
continue;
|
||||
|
||||
@@ -4,10 +4,9 @@ use super::{
|
||||
commands::{R, err},
|
||||
};
|
||||
use horizon_client::{
|
||||
bossbar::BossBars,
|
||||
bossbar::{self, BossBars},
|
||||
command::{Arg, Context},
|
||||
hud::{HudBatch, rgba},
|
||||
save,
|
||||
hud_overlays, save,
|
||||
scoreboard::{self as sb, Comp, comp_text},
|
||||
scoreboard_cmd::format_list,
|
||||
text::Text,
|
||||
@@ -334,26 +333,10 @@ impl Game<'_> {
|
||||
Ok(self.resolve_component(value))
|
||||
}
|
||||
|
||||
/// How many boss bars the dragon and withers already show.
|
||||
pub(super) fn builtin_bar_count(&self) -> usize {
|
||||
let dragon = usize::from(self.dragon.is_some() && self.dim == super::Dim::End);
|
||||
let withers = self.player.as_ref().map_or(0, |p| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.species() == horizon_client::mobs::Species::Wither
|
||||
&& m.alive()
|
||||
&& m.position.distance_squared(p.position) <= 64.0 * 64.0
|
||||
})
|
||||
.count()
|
||||
});
|
||||
dragon + withers
|
||||
}
|
||||
// ---- drawing ------------------------------------------------------------------
|
||||
|
||||
/// `BossHealthOverlay`: the visible custom bars under the built-in ones.
|
||||
pub(super) fn draw_custom_bossbars(&self, batch: &mut HudBatch, mut y: f32) {
|
||||
let atlas = &self.res.atlas;
|
||||
/// The visible custom bars (`/bossbar`), after the built-in ones.
|
||||
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
for b in self
|
||||
.bosses
|
||||
.bars
|
||||
@@ -361,44 +344,18 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|b| b.visible && b.player)
|
||||
{
|
||||
if y >= batch.height / 3.0 {
|
||||
break;
|
||||
}
|
||||
let x = (batch.width / 2.0 - 91.0).floor();
|
||||
let sprite = |kind: &str| format!("gui/sprites/boss_bar/{kind}");
|
||||
batch.sprite(atlas, &sprite(&format!("{}_background", b.color)), x, y);
|
||||
if b.progress() > 0.0 {
|
||||
batch.sprite_part(
|
||||
atlas,
|
||||
&sprite(&format!("{}_progress", b.color)),
|
||||
x,
|
||||
y,
|
||||
b.progress(),
|
||||
);
|
||||
}
|
||||
if b.style != "progress" {
|
||||
batch.sprite(atlas, &sprite(&format!("{}_background", b.style)), x, y);
|
||||
if b.progress() > 0.0 {
|
||||
batch.sprite_part(
|
||||
atlas,
|
||||
&sprite(&format!("{}_progress", b.style)),
|
||||
x,
|
||||
y,
|
||||
b.progress(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let name = comp_text(&b.name);
|
||||
let w = atlas.font.width_text(&name);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&name,
|
||||
(batch.width / 2.0 - w / 2.0).floor(),
|
||||
y - 9.0,
|
||||
rgba(255, 255, 255, 255),
|
||||
true,
|
||||
);
|
||||
y += 19.0;
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: comp_text(&b.name),
|
||||
progress: b.progress(),
|
||||
color: bossbar::COLORS
|
||||
.iter()
|
||||
.position(|c| *c == b.color)
|
||||
.unwrap_or(0),
|
||||
overlay: bossbar::STYLES
|
||||
.iter()
|
||||
.position(|s| *s == b.style)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,11 +282,22 @@ impl Game<'_> {
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
// The mob effect registry: every effect `/effect` and the
|
||||
// `mob_effect` resource arguments accept.
|
||||
reg.effects = horizon_client::brewing::EFFECTS
|
||||
.iter()
|
||||
.map(|e| format!("minecraft:{}", e.id))
|
||||
.collect();
|
||||
reg.effects.sort();
|
||||
reg.enchantments = list("data/minecraft/enchantment");
|
||||
reg.structures = list("data/minecraft/worldgen/structure");
|
||||
reg.biomes = list("data/minecraft/worldgen/biome");
|
||||
reg.clocks = list("data/minecraft/world_clock");
|
||||
reg.dimensions = vec!["minecraft:overworld".into(), "minecraft:the_nether".into()];
|
||||
reg.dimensions = vec![
|
||||
"minecraft:overworld".into(),
|
||||
"minecraft:the_nether".into(),
|
||||
"minecraft:the_end".into(),
|
||||
];
|
||||
reg.players = vec![PLAYER_NAME.into()];
|
||||
// Timelines of the overworld clock and their time markers.
|
||||
self.ui.markers.clear();
|
||||
@@ -324,6 +335,15 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Vanilla's built-in registries (mob effects, components, dialogs...).
|
||||
for dir in [&self.options.resources, &self.ui.vanilla_root] {
|
||||
if let Ok(bytes) = std::fs::read(dir.join("command-registries.json"))
|
||||
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
{
|
||||
self.ui.registries.fill_from_export(&v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let reg = &mut self.ui.registries;
|
||||
reg.timelines = self.ui.timelines.keys().cloned().collect();
|
||||
reg.time_markers = self
|
||||
@@ -368,7 +388,7 @@ impl Game<'_> {
|
||||
/// A chat message from the game (ChatComponent.addServerSystemMessage).
|
||||
pub(super) fn system_message(&mut self, text: Text) {
|
||||
if let Some(capture) = self.chat_capture.as_mut() {
|
||||
capture.push(text.plain());
|
||||
capture.push(text);
|
||||
return;
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -397,7 +417,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.res.atlas;
|
||||
let d = vanilla::dispatcher();
|
||||
let d = vanilla::game_dispatcher();
|
||||
if screen.suggestion_key(atlas, d, &self.ui.registries, key, shift) {
|
||||
self.ui.chat_screen = Some(screen);
|
||||
return;
|
||||
@@ -511,11 +531,11 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
screen.resize(batch.width, batch.height);
|
||||
let bg = self.ui.chat.options.background_opacity;
|
||||
let bg = self.ui.chat.options.background_opacity as f32;
|
||||
screen.draw(
|
||||
batch,
|
||||
&self.res.atlas,
|
||||
vanilla::dispatcher(),
|
||||
vanilla::game_dispatcher(),
|
||||
&self.ui.registries,
|
||||
self.cursor,
|
||||
bg,
|
||||
@@ -655,17 +675,21 @@ impl Game<'_> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let below_world = p.position.y < f64::from(horizon_client::chunk::MIN_Y - 64);
|
||||
let name = self.player_display_text();
|
||||
let message = match cause {
|
||||
Some(key) => Text::translate(&key, &[Text::literal(PLAYER_NAME)]),
|
||||
None if p.position.y < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
Text::translate("death.attack.outOfWorld", &[Text::literal(PLAYER_NAME)])
|
||||
}
|
||||
Some(key) => Text::translate(&key, &[name]),
|
||||
None if below_world => Text::translate("death.attack.outOfWorld", &[name]),
|
||||
None => self.combat_death_message(),
|
||||
};
|
||||
self.ui.death_text = Some(message.clone());
|
||||
self.system_message(message);
|
||||
// ServerPlayer.die: a team that hides death messages keeps it out of
|
||||
// the dying player's own chat (the death screen still shows it).
|
||||
if horizon_client::scoreboard::own_death_message_shown(self.player_team()) {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
/// F1 hides the HUD (and hand); the screenshot key still works.
|
||||
/// F1 hides the HUD (see `hud`); the screenshot key still works.
|
||||
pub(super) fn toggle_hide_gui(&mut self) {
|
||||
self.ui.hide_gui = !self.ui.hide_gui;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ pub(super) struct CombatTracker {
|
||||
pub(super) last_hurt_mob: Option<(u32, i64)>,
|
||||
/// The mob whose projectile is hitting the player now.
|
||||
pub(super) projectile_owner: Option<u32>,
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
/// The player's fall distance before this tick's movement (as of the
|
||||
/// end of the last one).
|
||||
fall_before: f64,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Headless command runner for `tools/test_command_exec_parity.py`: with
|
||||
//! `HORIZON_COMMAND_SCRIPT` and `HORIZON_COMMAND_OUTPUT` set, the opened world
|
||||
//! runs without a window. Each script line is a JSON operation: `cmd` runs a
|
||||
//! command as the player (Commands.performPrefixedCommand) and records every
|
||||
//! system message it produced, as styled spans, with the results reported to
|
||||
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
|
||||
//! waits until the terrain around the player is loaded.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, text::Text};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// A message as `[text, style]` runs with adjacent equal styles merged; the
|
||||
/// style lists colour, bold, italic, underlined, strikethrough, obfuscated
|
||||
/// and the suggested command.
|
||||
pub(super) fn spans(text: &Text) -> Value {
|
||||
let mut out: Vec<(String, Value)> = Vec::new();
|
||||
for span in &text.spans {
|
||||
if span.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let s = &span.style;
|
||||
let style = json!([
|
||||
s.color,
|
||||
s.bold == Some(true),
|
||||
s.italic == Some(true),
|
||||
s.underlined == Some(true),
|
||||
s.strikethrough == Some(true),
|
||||
s.obfuscated == Some(true),
|
||||
]);
|
||||
match out.last_mut() {
|
||||
Some((t, last)) if *last == style => t.push_str(&span.text),
|
||||
_ => out.push((span.text.clone(), style)),
|
||||
}
|
||||
}
|
||||
Value::Array(out.into_iter().map(|(t, s)| json!([t, s])).collect())
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Load terrain until the player stands in lit chunks.
|
||||
fn harness_settle(&mut self, deadline: Instant) -> Result<()> {
|
||||
loop {
|
||||
self.update_world();
|
||||
self.spawn_player()?;
|
||||
if let Some(p) = &self.player {
|
||||
let (cx, cz) = (
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
);
|
||||
if (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if std::env::var_os("HORIZON_HARNESS_DEBUG").is_some() {
|
||||
eprintln!(
|
||||
"settle: player {:?} center {:?} chunks {} pending {}",
|
||||
self.player.as_ref().map(|p| p.position),
|
||||
self.streaming.center,
|
||||
self.world.chunks.len(),
|
||||
self.streaming.pending()
|
||||
);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err("command harness: terrain did not load".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
pub(super) fn run_command_harness(&mut self, script: &Path, output: &Path) -> Result<()> {
|
||||
let deadline = Instant::now()
|
||||
+ Duration::from_secs(
|
||||
std::env::var("HORIZON_HARNESS_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(600),
|
||||
);
|
||||
self.harness_settle(deadline)?;
|
||||
let mut out = String::new();
|
||||
for line in std::fs::read_to_string(script)?.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let op: Value = serde_json::from_str(line)?;
|
||||
match op["op"].as_str().unwrap_or("") {
|
||||
"cmd" => {
|
||||
let command = op["cmd"].as_str().unwrap_or("");
|
||||
self.chat_capture = Some(Vec::new());
|
||||
self.cmds.results = Some(Vec::new());
|
||||
self.run_command(command.strip_prefix('/').unwrap_or(command));
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
let results = self.cmds.results.take().unwrap_or_default();
|
||||
if op["record"].as_bool() != Some(false) {
|
||||
let record = json!({
|
||||
"cmd": command,
|
||||
"messages": messages.iter().map(spans).collect::<Vec<_>>(),
|
||||
"results": results.iter().map(|(s, v)| json!([s, v])).collect::<Vec<_>>(),
|
||||
});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
// The oracle's player has no client moving it: hold the
|
||||
// player where commands put it while the game ticks.
|
||||
let held = self.player.as_ref().map(|p| p.position);
|
||||
for _ in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
self.update_world();
|
||||
self.tick()?;
|
||||
if let (Some(p), Some(at)) = (self.player.as_mut(), held) {
|
||||
p.position = at;
|
||||
p.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0);
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
if op["record"].as_bool() == Some(true) {
|
||||
let record = json!({
|
||||
"cmd": "#tick",
|
||||
"messages": messages.iter().map(spans).collect::<Vec<_>>(),
|
||||
"results": [],
|
||||
});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"settle" => self.harness_settle(deadline)?,
|
||||
other => return Err(format!("command harness: unknown op {other}").into()),
|
||||
}
|
||||
}
|
||||
std::fs::write(output, out)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Loaded 100 of 453 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user