mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
199
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f427dabe | ||
|
|
b156515edd | ||
|
|
0fdf1aeee1 | ||
|
|
ee2fb39605 | ||
|
|
559e36ee04 | ||
|
|
7037510e65 | ||
|
|
417a1cebc1 | ||
|
|
9edc7eb80a | ||
|
|
d9628314ab | ||
|
|
4984a1329a | ||
|
|
d22b63b9ca | ||
|
|
dc5e8c45f7 | ||
|
|
2782a9057a | ||
|
|
66620f22a3 | ||
|
|
18070bb4ce | ||
|
|
b575402076 | ||
|
|
5b3c3684b1 | ||
|
|
29b4b72dd5 | ||
|
|
9bc8447871 | ||
|
|
56c1100644 | ||
|
|
a881d284a7 | ||
|
|
87a6a7a161 | ||
|
|
5ceba01475 | ||
|
|
da9d0dcbe1 | ||
|
|
e2a9909521 | ||
|
|
19faf89699 | ||
|
|
f230017ec1 | ||
|
|
ba86a4d76d | ||
|
|
a734609ab4 | ||
|
|
eab3195848 | ||
|
|
0d2f7b86d5 | ||
|
|
e79992e80b | ||
|
|
248aa8b302 | ||
|
|
cf586e4159 | ||
|
|
7443236850 | ||
|
|
5e391eb673 | ||
|
|
c10553eebf | ||
|
|
215d8e779a | ||
|
|
58a4fd8263 | ||
|
|
3c63c5913e | ||
|
|
7f870d7447 | ||
|
|
f49659c78b | ||
|
|
62ff5241a4 | ||
|
|
4d460c7374 | ||
|
|
d99f7d6d7b | ||
|
|
49c6c47f98 | ||
|
|
b029efa3d3 | ||
|
|
37b4ee3abd | ||
|
|
b07f2775a2 | ||
|
|
e831cb0333 | ||
|
|
6a5cd3cc28 | ||
|
|
3b5643628a | ||
|
|
4a9501d2d9 | ||
|
|
8a290d810a | ||
|
|
43e7700791 | ||
|
|
4baa571566 | ||
|
|
a7e279cfed | ||
|
|
7c3364d30d | ||
|
|
2fe5fa0939 | ||
|
|
64990cd321 | ||
|
|
78b778463f | ||
|
|
76864807ba | ||
|
|
1f3cc4b80e | ||
|
|
1816cf0b52 | ||
|
|
258c5946b5 | ||
|
|
452224a28a | ||
|
|
f8988d5a73 | ||
|
|
184e1ee3d6 | ||
|
|
c5702d6c29 | ||
|
|
e6412d6e5c | ||
|
|
90b583896a | ||
|
|
7db6a085bf | ||
|
|
0b74b68aac | ||
|
|
1db57affe6 | ||
|
|
e1844d064e | ||
|
|
1231679c4d | ||
|
|
9e9b9862ee | ||
|
|
7c31259376 | ||
|
|
40c1ab898f | ||
|
|
13d7d9995d | ||
|
|
945a98f8bc | ||
|
|
7fa0fe50f7 | ||
|
|
611d1ef51e | ||
|
|
49866a4482 | ||
|
|
6a06853032 | ||
|
|
25f4545038 | ||
|
|
54e56ca215 | ||
|
|
92c462a9b1 | ||
|
|
b8f48f3ad0 | ||
|
|
abca441ad4 | ||
|
|
99b52c2617 | ||
|
|
77bfff8eb5 | ||
|
|
8ea9a2c849 | ||
|
|
cc15e8ebb3 | ||
|
|
dbeb13ff6b | ||
|
|
39d23ac256 | ||
|
|
d900488d12 | ||
|
|
e7827deabb | ||
|
|
d2d5dd00ad | ||
|
|
916150f02c | ||
|
|
5302f6a22a | ||
|
|
aa7a149858 | ||
|
|
504aa1dca6 | ||
|
|
3497737ecc | ||
|
|
9db690b1e0 | ||
|
|
f220157a0f | ||
|
|
ddd1dbdf91 | ||
|
|
3f28d6dbd1 | ||
|
|
5bc76cbba9 | ||
|
|
c3dceac2aa | ||
|
|
ced6323bb2 | ||
|
|
698e7fd662 | ||
|
|
7808916266 | ||
|
|
53beacfd13 | ||
|
|
ab9ceb6a6d | ||
|
|
46d466f7c3 | ||
|
|
ab640c7646 | ||
|
|
b1d3c9b92b | ||
|
|
2a7ee57bab | ||
|
|
624be7df86 | ||
|
|
48ab8b0934 | ||
|
|
fb27a63dce | ||
|
|
b27d4b85e1 | ||
|
|
d5a7e8420b | ||
|
|
bd80ffd540 | ||
|
|
1df28d1793 | ||
|
|
8bb8239603 | ||
|
|
702d7cf2cc | ||
|
|
38712c49ec | ||
|
|
a287f701e6 | ||
|
|
85032102a8 | ||
|
|
c3987df0b2 | ||
|
|
6c50b7d980 | ||
|
|
1fa4a780f0 | ||
|
|
3c514d5c17 | ||
|
|
833b9385ee | ||
|
|
5f80ddffce | ||
|
|
b9449b2348 | ||
|
|
dc3fc6961e | ||
|
|
fd0aeb5331 | ||
|
|
e9c6751a1b | ||
|
|
cf0b111bdd | ||
|
|
dbb03a42d3 | ||
|
|
01f3d91d04 | ||
|
|
99b6b8f3e8 | ||
|
|
fc409d28f4 | ||
|
|
fa727a627a | ||
|
|
511eb49629 | ||
|
|
a278572906 | ||
|
|
859b6be783 | ||
|
|
7826732a5c | ||
|
|
dcd733c7c1 | ||
|
|
e2b2b2ccd0 | ||
|
|
a49e72201a | ||
|
|
c6efb367b0 | ||
|
|
8339f6d12f | ||
|
|
0320e7d593 | ||
|
|
61713b8359 | ||
|
|
9d7b4e8a2d | ||
|
|
54360a74e3 | ||
|
|
cd97b34460 | ||
|
|
03f3fb057d | ||
|
|
8502d86e24 | ||
|
|
3fe4834ceb | ||
|
|
be8b86d470 | ||
|
|
f755d7fba0 | ||
|
|
b08e04cd8a | ||
|
|
a96f66ce1e | ||
|
|
b561f83220 | ||
|
|
4d0962e611 | ||
|
|
50ebc9de60 | ||
|
|
d480ce24dd | ||
|
|
f1a8cc6a0d | ||
|
|
736842d40f | ||
|
|
ba37fa75d6 | ||
|
|
e9fbcfef78 | ||
|
|
206b7acee4 | ||
|
|
be26c6db88 | ||
|
|
c06a7d4511 | ||
|
|
2dc154ff7f | ||
|
|
66b705cec3 | ||
|
|
780abb9832 | ||
|
|
dcb7476d12 | ||
|
|
826147d716 | ||
|
|
0b4fa473e7 | ||
|
|
045cabb7f2 | ||
|
|
b85ce82160 | ||
|
|
ea7df1d0dc | ||
|
|
fea8346f23 | ||
|
|
e36a480063 | ||
|
|
e5ca012ed8 | ||
|
|
ee9d8a656f | ||
|
|
dae67a88cc | ||
|
|
b201dafa75 | ||
|
|
7c58bf004f | ||
|
|
4119284a4f | ||
|
|
957a3c6622 | ||
|
|
1758b147b8 | ||
|
|
36ebd0c545 |
No files matched your search
@@ -16,12 +16,14 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
run: |
|
||||
# Every head the pull request had, not only its last: a run-parity label added on an older head left that
|
||||
# run queued after the merge (#3104, 2026-10-29). Its branch in its own repository names its runs (another
|
||||
# fork can have a branch of the same name).
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
|
||||
--json databaseId,event,headSha \
|
||||
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
|
||||
gh api -X GET "repos/$REPO/actions/runs" -f branch="$BRANCH" -f event=pull_request -f status="$status" \
|
||||
-f per_page=100 --jq ".workflow_runs[] | select(.head_repository.full_name == \"$HEAD_REPO\") | .id"
|
||||
done | sort -u | while read -r id; do
|
||||
gh run cancel -R "$REPO" "$id" || true
|
||||
done
|
||||
@@ -37,9 +37,10 @@ permissions:
|
||||
jobs:
|
||||
exporters:
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
|
||||
# goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game:
|
||||
# ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
|
||||
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }}
|
||||
# goes to the Mac mini's nightly runner (macOS + horizon-nightly: ludovics-mac-mini-nightly, beside the one that
|
||||
# runs pull requests' parity jobs, so a nightly never waits behind their queue), never the release runner
|
||||
# (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
|
||||
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]') }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -103,7 +104,7 @@ jobs:
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || matrix.os }}
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
|
||||
@@ -5,11 +5,15 @@ name: run-parity
|
||||
# skipped `verify`, and GitHub counts a skipped required check as passed.
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
# A push starts a run only to cancel the old head's (see concurrency): the job runs on the label alone.
|
||||
types: [labeled, synchronize]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: run-parity-${{ github.event.pull_request.number }}
|
||||
# The run-parity label and pushes share one group per pull request, so the newest run cancels the one before: a
|
||||
# pushed pull request's run tested a head the merge would no longer land. Any other label gets a group of its own:
|
||||
# in the shared one, adding needs-proof cancelled the parity run queued seconds before (run 40766180150).
|
||||
group: run-parity-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-parity' && format('-{0}', github.run_id) || '' }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -23,15 +23,15 @@
|
||||
|
||||
# Horizon
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
|
||||
> 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.
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -48,7 +48,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
|
||||
@@ -139,6 +139,11 @@ average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
|
||||
them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
|
||||
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
|
||||
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
|
||||
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| 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 |
|
||||
|
||||
+111
-30
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command.
|
||||
**Next**: A new launcher design; CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command; Ready-made setups for hosting panels.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
@@ -104,37 +104,49 @@ More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Native mods, built by the launcher
|
||||
|
||||
Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Stable hook points in the game (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346); v2 with the item panel and recipe pages, [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)), horizon-mixins applied by the launcher with its own portable Rust ([#3388](https://github.com/horizon-rust/horizon/issues/3388)), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353), [#3371](https://github.com/horizon-rust/horizon/issues/3371)).
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Mods that run on the server
|
||||
|
||||
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
API 0.3, experimental first ([#1833](https://github.com/horizon-rust/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
|
||||
The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 ([#1833](https://github.com/horizon-rust/horizon/issues/1833)) is the reference for what to cover.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Screens, input and drawing for mods
|
||||
|
||||
Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
|
||||
Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Capability `ui` is in review ([#1864](https://github.com/horizon-rust/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
On native hook points ([docs/native-mods.md](docs/native-mods.md)): settings pages under Options > Mods and the item panel are there ([#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3435](https://github.com/horizon-rust/horizon/issues/3435)). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.
|
||||
|
||||
</details>
|
||||
|
||||
#### Tools for people who make mods
|
||||
|
||||
Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
|
||||
Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/horizon-rust/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/horizon-rust/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
|
||||
|
||||
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
|
||||
More: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -244,6 +256,20 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Minecraft Java instances in the launcher
|
||||
|
||||
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
|
||||
|
||||
More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
@@ -268,6 +294,16 @@ A review of the whole tree: dead code and stubs removed ([#1432](https://github.
|
||||
|
||||
</details>
|
||||
|
||||
#### Every vanilla method, tracked
|
||||
|
||||
A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
[tools/port_ledger.py](tools/port_ledger.py) and tools/port_map/ ([#3139](https://github.com/horizon-rust/horizon/issues/3139)): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).
|
||||
|
||||
</details>
|
||||
|
||||
## Next
|
||||
|
||||
### Performance
|
||||
@@ -444,6 +480,16 @@ A player's idea (#horizon-ideas). `horizon-server` already runs headless; left:
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready-made setups for hosting panels
|
||||
|
||||
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Mods that read and change the world
|
||||
@@ -452,9 +498,9 @@ Mods that can see and change blocks, read inventories and items, and find and af
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
|
||||
Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.
|
||||
|
||||
More: [docs/modpacks.md](docs/modpacks.md)
|
||||
More: [docs/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -468,16 +514,6 @@ Block entities and menus: ticking machines with slots, server menu logic and the
|
||||
|
||||
</details>
|
||||
|
||||
#### Lua and other languages for mods
|
||||
|
||||
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Pistons push players and mobs
|
||||
@@ -536,15 +572,13 @@ More: [docs/shaders.md](docs/shaders.md)
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Microsoft sign-in (coming soon)
|
||||
#### A new launcher design
|
||||
|
||||
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
|
||||
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
|
||||
|
||||
More: [#1270](https://github.com/horizon-rust/horizon/issues/1270)
|
||||
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -708,6 +742,18 @@ Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.
|
||||
|
||||
</details>
|
||||
|
||||
#### Join servers on older versions
|
||||
|
||||
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game.
|
||||
|
||||
More: [#3023](https://github.com/horizon-rust/horizon/issues/3023)
|
||||
|
||||
</details>
|
||||
|
||||
#### Online-mode checks for Play with Friends
|
||||
|
||||
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
|
||||
@@ -720,6 +766,18 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Scripts for mods
|
||||
|
||||
Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Orbital Laser on the normal game
|
||||
|
||||
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
|
||||
@@ -732,13 +790,13 @@ A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/
|
||||
|
||||
#### Native versions of popular client mods
|
||||
|
||||
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
|
||||
Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
|
||||
Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays.
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
More: [docs/extras.md](docs/extras.md) · [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -784,6 +842,16 @@ The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read
|
||||
|
||||
</details>
|
||||
|
||||
#### Splitscreen
|
||||
|
||||
Play with a friend on the same screen, each with a controller.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054); a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
|
||||
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
|
||||
@@ -834,7 +902,7 @@ More graphics options: multisample anti-aliasing, anisotropic filtering and an e
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
|
||||
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA, and SMAA or TAA are asked for too; exclusive fullscreen where the backend supports it.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -862,6 +930,18 @@ Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 buil
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Windows ARM64
|
||||
|
||||
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on.
|
||||
|
||||
More: [docs/release-flow.md](docs/release-flow.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Android, iOS and the browser
|
||||
|
||||
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
|
||||
@@ -968,6 +1048,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
|
||||
@@ -11,13 +11,18 @@ pub fn parse_snbt(input: &str) -> Result<(Value, usize), String> {
|
||||
let mut p = Parser {
|
||||
s: input.as_bytes(),
|
||||
i: 0,
|
||||
depth: 0,
|
||||
};
|
||||
let v = p.value()?;
|
||||
Ok((v, p.i))
|
||||
}
|
||||
/// Nesting deeper than NBT's own limit (`NbtAccounter`'s 512) is refused
|
||||
/// rather than overflowing the stack.
|
||||
const SNBT_MAX_DEPTH: usize = 512;
|
||||
struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
depth: usize,
|
||||
}
|
||||
impl Parser<'_> {
|
||||
fn ws(&mut self) {
|
||||
@@ -41,57 +46,22 @@ impl Parser<'_> {
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b'{') => {
|
||||
self.i += 1;
|
||||
let mut map = Map::new();
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
loop {
|
||||
self.ws();
|
||||
let key = self.string()?;
|
||||
self.expect(b':')?;
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
_ => return Err("expected ',' or '}'".into()),
|
||||
}
|
||||
if self.depth >= SNBT_MAX_DEPTH {
|
||||
return Err(format!("tag nesting too deep at {}", self.i));
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.object();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
Some(b'[') => {
|
||||
self.i += 1;
|
||||
let mut list = Vec::new();
|
||||
self.ws();
|
||||
// Typed arrays [B;...], [I;...], [L;...].
|
||||
if matches!(
|
||||
self.s.get(self.i..self.i + 2),
|
||||
Some([b'B' | b'I' | b'L', b';'])
|
||||
) {
|
||||
self.i += 2;
|
||||
}
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
loop {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
_ => return Err("expected ',' or ']'".into()),
|
||||
}
|
||||
if self.depth >= SNBT_MAX_DEPTH {
|
||||
return Err(format!("tag nesting too deep at {}", self.i));
|
||||
}
|
||||
self.depth += 1;
|
||||
let v = self.array();
|
||||
self.depth -= 1;
|
||||
v
|
||||
}
|
||||
Some(b'"' | b'\'') => Ok(Value::String(self.string()?)),
|
||||
Some(_) => {
|
||||
@@ -105,10 +75,20 @@ impl Parser<'_> {
|
||||
_ => {
|
||||
let digits = word
|
||||
.trim_end_matches(['b', 'B', 's', 'S', 'l', 'L', 'f', 'F', 'd', 'D']);
|
||||
match (digits.parse::<i64>(), digits.parse::<f64>()) {
|
||||
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
|
||||
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
|
||||
_ => Value::String(word),
|
||||
// Only digits (and an exponent) make a number: `nan`
|
||||
// or `Infinity`, which Rust's parse would take, stays
|
||||
// a string.
|
||||
if digits
|
||||
.bytes()
|
||||
.any(|c| c.is_ascii_alphabetic() && c != b'e' && c != b'E')
|
||||
{
|
||||
Value::String(word)
|
||||
} else {
|
||||
match (digits.parse::<i64>(), digits.parse::<f64>()) {
|
||||
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
|
||||
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
|
||||
_ => Value::String(word),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -116,6 +96,59 @@ impl Parser<'_> {
|
||||
None => Err("expected a value".into()),
|
||||
}
|
||||
}
|
||||
fn object(&mut self) -> Result<Value, String> {
|
||||
self.i += 1;
|
||||
let mut map = Map::new();
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
loop {
|
||||
self.ws();
|
||||
let key = self.string()?;
|
||||
self.expect(b':')?;
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
_ => return Err("expected ',' or '}'".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn array(&mut self) -> Result<Value, String> {
|
||||
self.i += 1;
|
||||
let mut list = Vec::new();
|
||||
self.ws();
|
||||
// Typed arrays [B;...], [I;...], [L;...].
|
||||
if matches!(
|
||||
self.s.get(self.i..self.i + 2),
|
||||
Some([b'B' | b'I' | b'L', b';'])
|
||||
) {
|
||||
self.i += 2;
|
||||
}
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
loop {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
_ => return Err("expected ',' or ']'".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn unquoted(&mut self) -> String {
|
||||
let start = self.i;
|
||||
while let Some(c) = self.peek() {
|
||||
@@ -351,4 +384,22 @@ mod tests {
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.spans[0].style.font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snbt_nesting_is_bounded() {
|
||||
let nest = |n: usize| format!("{}1{}", "[".repeat(n), "]".repeat(n));
|
||||
assert!(parse_snbt(&nest(512)).is_ok());
|
||||
assert!(parse_snbt(&nest(513)).is_err());
|
||||
assert!(parse_snbt(&nest(100_000)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_words_that_look_numeric_stay_strings() {
|
||||
let (v, _) = parse_snbt("{text:nan}").unwrap();
|
||||
assert_eq!(v["text"], Value::String("nan".into()));
|
||||
let (v, _) = parse_snbt("{text:Infinity}").unwrap();
|
||||
assert_eq!(v["text"], Value::String("Infinity".into()));
|
||||
let (v, _) = parse_snbt("{n:1.5}").unwrap();
|
||||
assert_eq!(v["n"], Value::from(1.5));
|
||||
}
|
||||
}
|
||||
@@ -403,31 +403,7 @@ impl Text {
|
||||
continue;
|
||||
}
|
||||
let Some(code) = chars.next() else { break };
|
||||
let code = code.to_ascii_lowercase();
|
||||
let next = if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
|
||||
// Style.applyLegacyFormat: a colour clears the decorations.
|
||||
Style {
|
||||
color: Some(c.2),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underlined: Some(false),
|
||||
strikethrough: Some(false),
|
||||
obfuscated: Some(false),
|
||||
..style.clone()
|
||||
}
|
||||
} else {
|
||||
let mut s = style.clone();
|
||||
match code {
|
||||
'k' => s.obfuscated = Some(true),
|
||||
'l' => s.bold = Some(true),
|
||||
'm' => s.strikethrough = Some(true),
|
||||
'n' => s.underlined = Some(true),
|
||||
'o' => s.italic = Some(true),
|
||||
'r' => s = span.style.clone(),
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
};
|
||||
let next = legacy_format(&style, code, &span.style);
|
||||
if next != style {
|
||||
if !current.is_empty() {
|
||||
out.spans.push(Span {
|
||||
@@ -588,6 +564,35 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `Style.applyLegacyFormat` for the code after a `§`, `§r` going back to
|
||||
/// `reset` (`StringDecomposer.iterateFormatted`); an unknown code changes nothing.
|
||||
pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
|
||||
let code = code.to_ascii_lowercase();
|
||||
if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
|
||||
// A colour clears the decorations.
|
||||
return Style {
|
||||
color: Some(c.2),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underlined: Some(false),
|
||||
strikethrough: Some(false),
|
||||
obfuscated: Some(false),
|
||||
..style.clone()
|
||||
};
|
||||
}
|
||||
let mut s = style.clone();
|
||||
match code {
|
||||
'k' => s.obfuscated = Some(true),
|
||||
'l' => s.bold = Some(true),
|
||||
'm' => s.strikethrough = Some(true),
|
||||
'n' => s.underlined = Some(true),
|
||||
'o' => s.italic = Some(true),
|
||||
'r' => s = reset.clone(),
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
|
||||
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
|
||||
/// shown verbatim.
|
||||
|
||||
@@ -14,14 +14,19 @@ pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
potion, potion_color,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
let secs = ticks / 20;
|
||||
format_tick_duration(ticks, 20.0)
|
||||
}
|
||||
/// `StringUtil.formatTickDuration`: mm:ss, or hh:mm:ss from an hour up, every
|
||||
/// field two digits.
|
||||
pub fn format_tick_duration(ticks: i32, tick_rate: f32) -> String {
|
||||
let secs = (ticks as f32 / tick_rate).floor() as i32;
|
||||
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
format!("{h:02}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
format!("{m:02}:{s:02}")
|
||||
}
|
||||
}
|
||||
/// `item.minecraft.potion.effect.<name>` style translation key for a potion item.
|
||||
@@ -317,10 +322,14 @@ mod tests {
|
||||
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
|
||||
"item.minecraft.splash_potion.effect.swiftness"
|
||||
);
|
||||
assert_eq!(format_duration(9600), "8:00");
|
||||
assert_eq!(format_duration(450), "0:22");
|
||||
assert_eq!(format_duration(9600), "08:00");
|
||||
assert_eq!(format_duration(450), "00:22");
|
||||
assert_eq!(format_duration(140), "00:07");
|
||||
assert_eq!(format_duration(1800), "01:30");
|
||||
assert_eq!(format_duration(72000), "01:00:00");
|
||||
assert_eq!(format_tick_duration(9600, 40.0), "04:00");
|
||||
let t = tooltip(Some("minecraft:strong_poison"), 1.0, &|_| None);
|
||||
assert_eq!(t[0].0, "poison II (0:21)");
|
||||
assert_eq!(t[0].0, "poison II (00:21)");
|
||||
assert_eq!(POTIONS.len(), 46);
|
||||
}
|
||||
|
||||
|
||||
@@ -653,18 +653,57 @@ pub fn modify_fov_based_on_death_or_fluid(
|
||||
fov
|
||||
}
|
||||
|
||||
/// `ClientAvatarState`: the walked distance and view bob of a player.
|
||||
/// `ClientAvatarState`: the walked distance, view bob and cloak 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,
|
||||
/// `xCloak`, `yCloak`, `zCloak`: a point trailing the player, which the cape leans from.
|
||||
pub cloak: [f64; 3],
|
||||
pub cloak_o: [f64; 3],
|
||||
}
|
||||
impl AvatarBob {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
|
||||
pub fn tick(&mut self) {
|
||||
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`)
|
||||
/// with the player's position then: `moveCloak` eases the cloak a quarter
|
||||
/// of the way there, or puts it there from 10 blocks away.
|
||||
pub fn tick(&mut self, position: [f64; 3]) {
|
||||
self.walk_dist_o = self.walk_dist;
|
||||
self.cloak_o = self.cloak;
|
||||
for i in 0..3 {
|
||||
let d = position[i] - self.cloak[i];
|
||||
if d > 10.0 || d < -10.0 {
|
||||
self.cloak[i] = position[i];
|
||||
self.cloak_o[i] = position[i];
|
||||
} else {
|
||||
self.cloak[i] += d * 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AvatarRenderer.extractCapeState`: the cape's `capeFlap`, `capeLean`
|
||||
/// and `capeLean2` (degrees) for the player at `position` (interpolated)
|
||||
/// with its body turned `body_yaw` and its `fallFlyingScale`.
|
||||
pub fn cape(
|
||||
&self,
|
||||
partial: f32,
|
||||
position: [f64; 3],
|
||||
body_yaw: f32,
|
||||
fall_flying_scale: f32,
|
||||
) -> [f32; 3] {
|
||||
let d: [f64; 3] = std::array::from_fn(|i| {
|
||||
lerp_d(f64::from(partial), self.cloak_o[i], self.cloak[i]) - position[i]
|
||||
});
|
||||
let rad = f64::from(body_yaw * DEG_TO_RAD);
|
||||
let (sin, cos) = (f64::from(trig::sin(rad)), f64::from(-trig::cos(rad)));
|
||||
let mut flap = (d[1] as f32 * 10.0).clamp(-6.0, 32.0);
|
||||
let lean = ((d[0] * sin + d[2] * cos) as f32 * 100.0 * (1.0 - fall_flying_scale))
|
||||
.clamp(0.0, 150.0);
|
||||
let lean2 = ((d[0] * cos - d[2] * sin) as f32 * 100.0).clamp(-20.0, 20.0);
|
||||
let bob = self.interpolated_bob(partial);
|
||||
let walk = lerp(partial, self.walk_dist_o, self.walk_dist);
|
||||
flap += trig::sin(f64::from(walk * 6.0)) * 32.0 * bob;
|
||||
[flap, lean, lean2]
|
||||
}
|
||||
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
|
||||
/// with the float horizontal displacement.
|
||||
@@ -795,6 +834,23 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
|
||||
/// cape leans back as the player walks forward (south, body yaw 0).
|
||||
#[test]
|
||||
fn the_cloak_trails_the_player() {
|
||||
let mut a = super::AvatarBob::default();
|
||||
a.tick([0.0, 64.0, 0.0]);
|
||||
assert_eq!((a.cloak, a.cloak_o), ([0.0, 64.0, 0.0], [0.0, 64.0, 0.0]));
|
||||
a.tick([0.0, 64.0, 1.0]);
|
||||
assert_eq!(a.cloak, [0.0, 64.0, 0.25]);
|
||||
let [flap, lean, lean2] = a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 0.0);
|
||||
assert_eq!((flap, lean, lean2), (0.0, 75.0, 0.0));
|
||||
// Falling lifts the flap, at most 32 degrees.
|
||||
assert_eq!(a.cape(1.0, [0.0, 60.0, 0.25], 0.0, 0.0)[0], 32.0);
|
||||
// Gliding takes the lean away.
|
||||
assert_eq!(a.cape(1.0, [0.0, 64.0, 1.0], 0.0, 1.0)[1], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_far_covers_the_cloud_range() {
|
||||
// renderDistance 2 with the default Cloud Distance (128 chunks).
|
||||
|
||||
@@ -42,6 +42,45 @@ pub fn recipe_used_key(id: &str) -> String {
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
|
||||
/// translation key of its menu's title when it has no custom name. Copper
|
||||
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
|
||||
/// `ShulkerBoxBlockEntity`.
|
||||
pub fn default_name_key(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
|
||||
n if n.ends_with("copper_chest") => "container.chest",
|
||||
"minecraft:barrel" => "container.barrel",
|
||||
n if n.ends_with("shulker_box") => "container.shulkerBox",
|
||||
"minecraft:dispenser" => "container.dispenser",
|
||||
"minecraft:dropper" => "container.dropper",
|
||||
"minecraft:hopper" => "container.hopper",
|
||||
"minecraft:furnace" => "container.furnace",
|
||||
"minecraft:smoker" => "container.smoker",
|
||||
"minecraft:blast_furnace" => "container.blast_furnace",
|
||||
"minecraft:brewing_stand" => "container.brewing",
|
||||
"minecraft:crafter" => "container.crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
|
||||
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
|
||||
/// name (`CustomName`, a literal), else its default name.
|
||||
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
|
||||
match custom {
|
||||
Some(name) => horizon_protocol::text::literal(name),
|
||||
None => horizon_protocol::text::translatable(default_key, &[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
|
||||
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
|
||||
/// `FIRST`), else the second's, else `container.chestDouble`.
|
||||
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
|
||||
display_name(first.or(second), "container.chestDouble")
|
||||
}
|
||||
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
@@ -378,6 +417,58 @@ impl BlockEntity {
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
|
||||
/// A `ChestBlock`, which `ChestBlock.combine` can join into a double chest:
|
||||
/// chests, trapped chests and copper chests in every weathering state, waxed
|
||||
/// or not (`CopperChestBlock` extends `ChestBlock`). Ender chests are
|
||||
/// `EnderChestBlock`s and always single.
|
||||
pub fn is_chest_block(name: &str) -> bool {
|
||||
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
|
||||
}
|
||||
|
||||
/// A chest block's name with its `type` and `facing` properties.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChestHalf<'a> {
|
||||
pub block: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub facing: &'a str,
|
||||
}
|
||||
|
||||
impl ChestHalf<'_> {
|
||||
/// `ChestBlock.getConnectedDirection` as an (x, z) step to the other
|
||||
/// half: clockwise from the facing for a left half, counter-clockwise for
|
||||
/// a right one. None for a single chest.
|
||||
pub fn connected_step(&self) -> Option<(i32, i32)> {
|
||||
let step = |facing: &str| match facing {
|
||||
"north" => Some((0, -1)),
|
||||
"south" => Some((0, 1)),
|
||||
"west" => Some((-1, 0)),
|
||||
"east" => Some((1, 0)),
|
||||
_ => None,
|
||||
};
|
||||
let (x, z) = step(self.facing)?;
|
||||
// Turning clockwise seen from above takes (x, z) to (-z, x).
|
||||
match self.kind {
|
||||
"left" => Some((-z, x)),
|
||||
"right" => Some((z, -x)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DoubleBlockCombiner.combineWithNeigbour` as `ChestBlock.combine` calls
|
||||
/// it: `other`, the block in this half's connected direction, is its other
|
||||
/// half when it is the same block, a half of the other side and faces the
|
||||
/// same way. Copper chests in different weathering states never join
|
||||
/// (`CopperChestBlock` keeps both halves in one state instead).
|
||||
pub fn pairs_with(&self, other: &ChestHalf) -> bool {
|
||||
is_chest_block(self.block)
|
||||
&& self.kind != "single"
|
||||
&& other.block == self.block
|
||||
&& other.kind != "single"
|
||||
&& other.kind != self.kind
|
||||
&& other.facing == self.facing
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
pub fn create_experience(
|
||||
|
||||
@@ -22,6 +22,7 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
|
||||
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
/// The camera entity is a spectator (`LavaFogEnvironment`).
|
||||
pub spectator: bool,
|
||||
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
|
||||
pub fire_resistance: bool,
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water { water_vision } else { 0.0 };
|
||||
let brighten = if eye_in_water {
|
||||
water_vision
|
||||
} else {
|
||||
input.night_vision
|
||||
};
|
||||
if color.iter().all(|&c| c != 0.0) {
|
||||
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
|
||||
color = color.map(|c| c + (c * scale - c) * brighten);
|
||||
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
};
|
||||
match input.fog_type {
|
||||
FogType::Lava => {
|
||||
fog.environmental_start = 0.25;
|
||||
fog.environmental_end = 1.0;
|
||||
fog.sky_end = 1.0;
|
||||
fog.cloud_end = 1.0;
|
||||
// LavaFogEnvironment.setupFog.
|
||||
(fog.environmental_start, fog.environmental_end) = if input.spectator {
|
||||
(-8.0, rd * 0.5)
|
||||
} else if input.fire_resistance {
|
||||
(0.0, 5.0)
|
||||
} else {
|
||||
(0.25, 1.0)
|
||||
};
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
FogType::PowderSnow => {
|
||||
fog.environmental_start = 0.0;
|
||||
@@ -405,6 +422,9 @@ mod tests {
|
||||
darkness: None,
|
||||
darken_world: 0.0,
|
||||
boss_world_fog: false,
|
||||
spectator: false,
|
||||
fire_resistance: false,
|
||||
night_vision: 0.0,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! offsets), styled drawing and StringSplitter-style line wrapping.
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
text::{Style, Text},
|
||||
text::{Span, Style, Text, legacy_format},
|
||||
zip::Zip,
|
||||
};
|
||||
use ab_glyph::{Font as _, ScaleFont as _};
|
||||
@@ -966,6 +966,83 @@ impl Font {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `Font.split`: `StringSplitter.splitLines(text, width, Style.EMPTY)` over
|
||||
/// the text's parts with their `§` codes in. A code styles what follows
|
||||
/// (`§r` back to no style while measuring), and the part a line breaks in
|
||||
/// goes on in the style at the break. Lines keep their codes for drawing.
|
||||
pub fn split_formatted(&self, t: &Text, max_width: f32) -> Vec<Text> {
|
||||
let max_width = max_width.max(1.0);
|
||||
let mut parts: Vec<(Vec<char>, Style)> = t
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| !s.text.is_empty())
|
||||
.map(|s| (s.text.chars().collect(), s.style.clone()))
|
||||
.collect();
|
||||
let mut flat: Vec<char> = parts.iter().flat_map(|p| p.0.iter().copied()).collect();
|
||||
let mut out = Vec::new();
|
||||
let mut force_new_line = false;
|
||||
// One LineBreakFinder per line, over the parts left.
|
||||
while let Some((at, split_style)) = self.line_break(&parts, max_width) {
|
||||
let tail = flat[at];
|
||||
let new_line = tail == '\n';
|
||||
let skip = usize::from(new_line || tail == ' ');
|
||||
force_new_line = new_line;
|
||||
out.push(split_at(&mut parts, at, skip, &split_style));
|
||||
flat.drain(..at + skip);
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
let mut rest = Text::default();
|
||||
rest.spans = parts
|
||||
.into_iter()
|
||||
.map(|(text, style)| Span {
|
||||
text: text.into_iter().collect(),
|
||||
style,
|
||||
})
|
||||
.collect();
|
||||
out.push(rest);
|
||||
} else if force_new_line {
|
||||
out.push(Text::empty());
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `StringSplitter.LineBreakFinder` fed by `StringDecomposer.iterateFormatted`:
|
||||
/// where the parts break (a character index) and the style there.
|
||||
fn line_break(&self, parts: &[(Vec<char>, Style)], max_width: f32) -> Option<(usize, Style)> {
|
||||
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
|
||||
let mut offset = 0;
|
||||
for (contents, part_style) in parts {
|
||||
let mut style = part_style.clone();
|
||||
let mut i = 0;
|
||||
while i < contents.len() {
|
||||
let c = contents[i];
|
||||
if c == '§' {
|
||||
// A `§` that ends its part ends only that part.
|
||||
let Some(&code) = contents.get(i + 1) else {
|
||||
break;
|
||||
};
|
||||
style = legacy_format(&style, code, &Style::default());
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let at = offset + i;
|
||||
if c == '\n' {
|
||||
return Some((at, style));
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some((at, style.clone()));
|
||||
}
|
||||
let advance = self.advance(c, &style);
|
||||
width += advance;
|
||||
if had_visible && width > max_width {
|
||||
return Some(last_space.unwrap_or((at, style)));
|
||||
}
|
||||
had_visible |= advance != 0.0;
|
||||
i += 1;
|
||||
}
|
||||
offset += contents.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `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> {
|
||||
@@ -1059,6 +1136,47 @@ impl Font {
|
||||
}
|
||||
}
|
||||
|
||||
/// `StringSplitter.FlatComponents.splitAt`: the parts before `at` as a line,
|
||||
/// `skip` characters dropped, and the part it ends in restyled as `style`.
|
||||
fn split_at(parts: &mut Vec<(Vec<char>, Style)>, at: usize, skip: usize, style: &Style) -> Text {
|
||||
let mut line = Text::default();
|
||||
let mut push = |text: &[char], style: &Style| {
|
||||
line.spans.push(Span {
|
||||
text: text.iter().collect(),
|
||||
style: style.clone(),
|
||||
})
|
||||
};
|
||||
// Each part done is removed, so the one at hand is always the first.
|
||||
let (mut position, mut in_skip) = (at, false);
|
||||
while let Some((text, part_style)) = parts.first() {
|
||||
let size = text.len();
|
||||
if !in_skip {
|
||||
if position > size {
|
||||
push(text, part_style);
|
||||
parts.remove(0);
|
||||
position -= size;
|
||||
continue;
|
||||
}
|
||||
if position > 0 {
|
||||
push(&text[..position], part_style);
|
||||
}
|
||||
position += skip;
|
||||
in_skip = true;
|
||||
}
|
||||
if position <= size {
|
||||
if position == size {
|
||||
parts.remove(0);
|
||||
} else {
|
||||
parts[0] = (text[position..].to_vec(), style.clone());
|
||||
}
|
||||
break;
|
||||
}
|
||||
parts.remove(0);
|
||||
position -= size;
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1221,6 +1339,37 @@ mod tests {
|
||||
assert!(!font.take_patches().is_empty());
|
||||
}
|
||||
|
||||
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
|
||||
/// part only; the next part is still measured and can break.
|
||||
#[test]
|
||||
fn a_section_sign_ending_a_part_still_breaks_the_next() {
|
||||
let sheet = png(16, 8, |x, _| x < 2);
|
||||
let default = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let root = root(
|
||||
"section-sign",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/minecraft/textures/font/a.png", &sheet),
|
||||
],
|
||||
);
|
||||
let font = load(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut text = Text::default();
|
||||
text.spans = ["a§", "a a"]
|
||||
.map(|part| Span {
|
||||
text: part.into(),
|
||||
style: Style::default(),
|
||||
})
|
||||
.into();
|
||||
// 3 + 3 + 4 + 3 is past 12: the line breaks at the space.
|
||||
let lines: Vec<String> = font
|
||||
.split_formatted(&text, 12.0)
|
||||
.iter()
|
||||
.map(Text::plain)
|
||||
.collect();
|
||||
assert_eq!(lines, ["a§a", "a"]);
|
||||
}
|
||||
|
||||
/// A renderer rebuilt over the same font (the loading screen's handoff)
|
||||
/// makes its atlas texture from the textures alone, so the glyphs it had
|
||||
/// rasterized into the previous texture must be placed into the new one.
|
||||
|
||||
@@ -39,8 +39,9 @@ mod entity_outline;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
|
||||
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
|
||||
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
WATER_MASK_OVERLAY,
|
||||
};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
@@ -93,6 +94,7 @@ mod page_runs;
|
||||
pub mod panorama;
|
||||
mod resident;
|
||||
mod stars;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
pub(crate) mod upload;
|
||||
@@ -188,6 +190,8 @@ struct Globals {
|
||||
outline_params: [f32; 4],
|
||||
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
|
||||
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
struct SectionGpu {
|
||||
@@ -425,6 +429,7 @@ pub struct Renderer {
|
||||
lightning_pipeline: wgpu::RenderPipeline,
|
||||
lightning: lightning::Renderer,
|
||||
see_through_pipeline: wgpu::RenderPipeline,
|
||||
text_polygon_offset_pipeline: wgpu::RenderPipeline,
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
@@ -533,7 +538,7 @@ pub struct Renderer {
|
||||
block_entity_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
/// Resolution scale, FXAA and TAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
@@ -765,8 +770,14 @@ fn mip_chain(
|
||||
/// pass is also what passes each draw's first instance to shaders reading
|
||||
/// `instance_index`, and DirectX 12 is the fallback, where working matters
|
||||
/// more than CPU time.
|
||||
fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
|
||||
fn instance_descriptor(
|
||||
backends: wgpu::Backends,
|
||||
window: Option<&Arc<Window>>,
|
||||
) -> wgpu::InstanceDescriptor {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
if let Some(window) = window {
|
||||
descriptor = descriptor.with_display_handle(Box::new(WindowDisplay(window.clone())));
|
||||
}
|
||||
descriptor.backends = backends.with_env();
|
||||
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
|
||||
if !cfg!(debug_assertions) && !dx12 {
|
||||
@@ -776,6 +787,21 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor {
|
||||
}
|
||||
descriptor.with_env()
|
||||
}
|
||||
/// The window's display connection, so the OpenGL backend creates its EGL
|
||||
/// display from the X11 or Wayland connection the window lives on. Without
|
||||
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
|
||||
/// cannot present to a window; the backend then reports the surface as
|
||||
/// incompatible and the game has no OpenGL to fall back to (`gl not
|
||||
/// compatible with provided surface`).
|
||||
#[derive(Debug, Clone)]
|
||||
struct WindowDisplay(Arc<Window>);
|
||||
impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
|
||||
fn display_handle(
|
||||
&self,
|
||||
) -> std::result::Result<wgpu::rwh::DisplayHandle<'_>, wgpu::rwh::HandleError> {
|
||||
wgpu::rwh::HasDisplayHandle::display_handle(&*self.0)
|
||||
}
|
||||
}
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
@@ -890,7 +916,7 @@ async fn open_device(
|
||||
if attempt > 0 {
|
||||
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
|
||||
}
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends));
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
@@ -1128,7 +1154,14 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone(), hdr::output(&caps))
|
||||
let hdr_output = hdr::output(&caps);
|
||||
if previous.is_none() {
|
||||
log::info!(
|
||||
"Horizon renderer: HDR output {}",
|
||||
hdr_output.map_or("not offered by this display", hdr::Output::name)
|
||||
);
|
||||
}
|
||||
(config, caps.present_modes.clone(), hdr_output)
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
@@ -2016,6 +2049,21 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.TEXT_POLYGON_OFFSET: a text display's text and
|
||||
// background win against the surface they lie on (bias 1, 10).
|
||||
let text_polygon_offset_pipeline = entity_pipeline(
|
||||
"Polygon offset text",
|
||||
None,
|
||||
true,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let entity_pipeline = entity_pipeline(
|
||||
"Entities",
|
||||
None,
|
||||
@@ -2573,6 +2621,7 @@ impl Renderer {
|
||||
lightning_pipeline,
|
||||
lightning: lightning::Renderer::new(),
|
||||
see_through_pipeline,
|
||||
text_polygon_offset_pipeline,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
end_sky: end_sky::EndSky::default(),
|
||||
@@ -3206,6 +3255,19 @@ impl Renderer {
|
||||
}
|
||||
config
|
||||
}
|
||||
/// The F3 screen's HDR line while the setting is on: the swapchain HDR presents to with
|
||||
/// its paper white and peak, or that the surface offers none.
|
||||
pub fn hdr_line(&self) -> String {
|
||||
match &self.hdr {
|
||||
Some(hdr) => format!(
|
||||
"HDR: {}, {}/{} nits",
|
||||
hdr.output.name(),
|
||||
hdr.paper_white.round(),
|
||||
hdr.peak.round()
|
||||
),
|
||||
None => "HDR: not offered by this display".into(),
|
||||
}
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
|
||||
@@ -3222,6 +3284,14 @@ impl Renderer {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
match output {
|
||||
Some(output) => log::info!(
|
||||
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
|
||||
output.name(),
|
||||
output.format()
|
||||
),
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
|
||||
@@ -3231,9 +3301,12 @@ impl Renderer {
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool) {
|
||||
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias;
|
||||
self.world_post.antialias = antialias && !taa;
|
||||
self.world_post.taa = taa;
|
||||
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
|
||||
}
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
|
||||
@@ -3615,6 +3688,16 @@ impl Renderer {
|
||||
frame.boss_darken,
|
||||
);
|
||||
let view_proj = self.view_projection(frame);
|
||||
// TAA runs where the world post pass does, not under a shader pack's own.
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
|
||||
let jittered = self.world_post.jitter(
|
||||
[self.config.width, self.config.height],
|
||||
taa,
|
||||
view_proj,
|
||||
frame.camera,
|
||||
) * view_proj;
|
||||
if frame.title {
|
||||
self.panorama.prepare(
|
||||
&self.queue,
|
||||
@@ -3666,7 +3749,7 @@ impl Renderer {
|
||||
)
|
||||
};
|
||||
let globals = Globals {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
view_proj: jittered.to_cols_array_2d(),
|
||||
fog_color,
|
||||
fog,
|
||||
sky_color: [
|
||||
@@ -3706,6 +3789,7 @@ impl Renderer {
|
||||
}
|
||||
lights
|
||||
},
|
||||
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
@@ -4208,6 +4292,7 @@ impl Renderer {
|
||||
}
|
||||
let [
|
||||
entity_quads,
|
||||
text_quads,
|
||||
crumbling_quads,
|
||||
translucent_crumbling_quads,
|
||||
water_mask_quads,
|
||||
@@ -4220,6 +4305,7 @@ impl Renderer {
|
||||
.update(entities, frame.additive, frame.hand, frame.see_through);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads
|
||||
@@ -4298,7 +4384,7 @@ impl Renderer {
|
||||
self.stars.prepare(
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
view_proj * rotation,
|
||||
jittered * rotation,
|
||||
brightness,
|
||||
);
|
||||
}
|
||||
@@ -4471,6 +4557,8 @@ impl Renderer {
|
||||
.flatten();
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
let keep_depth = pack_reads_world || (taa && self.world_post.taa);
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
@@ -4622,8 +4710,7 @@ impl Renderer {
|
||||
view: world_depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
|
||||
store: if cull.is_some() || pack_reads_world {
|
||||
store: if cull.is_some() || keep_depth {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
@@ -4698,7 +4785,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_reads_world,
|
||||
keep_depth,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -4728,7 +4815,7 @@ impl Renderer {
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_reads_world,
|
||||
keep_depth,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -4757,6 +4844,13 @@ impl Renderer {
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if text_quads > 0 {
|
||||
pass.set_pipeline(&self.text_polygon_offset_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 {
|
||||
pass.set_pipeline(&self.translucent_item_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -4776,7 +4870,11 @@ impl Renderer {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..crumbling_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.draw_indexed(
|
||||
0..crumbling_quads * 6,
|
||||
((entity_quads + text_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if shadow_quads > 0 {
|
||||
@@ -4802,7 +4900,8 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..water_mask_quads * 6,
|
||||
((entity_quads + crumbling_quads + translucent_crumbling_quads) * 4) as i32,
|
||||
((entity_quads + text_quads + crumbling_quads + translucent_crumbling_quads)
|
||||
* 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -4820,6 +4919,7 @@ impl Renderer {
|
||||
pass.draw_indexed(
|
||||
0..swirl_quads * 6,
|
||||
((entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads)
|
||||
@@ -4913,7 +5013,7 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_crumbling_quads * 6,
|
||||
((entity_quads + crumbling_quads) * 4) as i32,
|
||||
((entity_quads + text_quads + crumbling_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
}
|
||||
@@ -5041,7 +5141,8 @@ impl Renderer {
|
||||
native.timing.as_mut(),
|
||||
);
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
self.world_post
|
||||
.finish(&self.queue, &mut encoder, &view_target);
|
||||
}
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(&mut encoder);
|
||||
@@ -5516,6 +5617,7 @@ mod tests {
|
||||
(11, 2),
|
||||
(15, 10),
|
||||
(7, 3),
|
||||
(35, 11),
|
||||
(4, 4),
|
||||
(12, 7),
|
||||
]
|
||||
@@ -5525,7 +5627,7 @@ mod tests {
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 0, 0, 1, 1, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -5533,7 +5635,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|q| q[0].light)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 3, 4, 2, 7, 9, 10, 5, 6, 8]
|
||||
[0, 1, 3, 4, 11, 2, 7, 9, 10, 5, 6, 8]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
@@ -5704,7 +5806,7 @@ mod tests {
|
||||
#[test]
|
||||
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
|
||||
let validated = |backends| {
|
||||
super::instance_descriptor(backends)
|
||||
super::instance_descriptor(backends, None)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
|
||||
};
|
||||
|
||||
@@ -103,10 +103,11 @@ impl SpriteAnimator {
|
||||
let mut counts: Vec<usize> = Vec::new();
|
||||
let mut sprites = Vec::with_capacity(animations.len());
|
||||
for animation in animations {
|
||||
let chains: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| source_chain(animation.pixels(frame), size, settings, animation.layer))
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|frame| animation.pixels(frame))
|
||||
.collect();
|
||||
let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum();
|
||||
let (chains, edge) = source_chains(&pixels, size, settings, animation.layer);
|
||||
let bytes: u64 = chains.iter().map(|chain| chain.len() as u64).sum();
|
||||
if bytes > limit {
|
||||
log::warn!(
|
||||
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
|
||||
@@ -124,7 +125,7 @@ impl SpriteAnimator {
|
||||
let data = &mut frames[buffer];
|
||||
let offsets = chains
|
||||
.iter()
|
||||
.map(|(chain, _)| {
|
||||
.map(|chain| {
|
||||
let offset = (data.len() / 4) as u32;
|
||||
data.extend_from_slice(chain);
|
||||
offset
|
||||
@@ -142,7 +143,7 @@ impl SpriteAnimator {
|
||||
shown: None,
|
||||
layer: animation.layer % split,
|
||||
frames: offsets,
|
||||
edge: chains[0].1,
|
||||
edge,
|
||||
group,
|
||||
});
|
||||
}
|
||||
@@ -424,15 +425,63 @@ fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u
|
||||
/// array's size from its own size's chain, its first level repeated into the
|
||||
/// larger levels: that chain, which the shader repeats. Any other sprite: its
|
||||
/// chain at the array's size.
|
||||
#[cfg(test)]
|
||||
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
|
||||
let native = settings.get(layer as usize).map_or(0, |s| s.native_size);
|
||||
let edge = if native.is_power_of_two() && native < size {
|
||||
native
|
||||
let (mut chains, edge) = source_chains(&[pixels], size, settings, layer);
|
||||
(chains.swap_remove(0), edge)
|
||||
}
|
||||
|
||||
/// Every frame's chain, laid out as one frame's `source_chain`, but mipmapped as
|
||||
/// vanilla's `SpriteContents.increaseMipLevel` does: over the whole animation
|
||||
/// sheet (the frames stacked), so `solidify` and the alpha-to-coverage scale
|
||||
/// of each level are the sheet's, not each frame's. A frame alone has a
|
||||
/// single texel square at its 2x2 level, and its coverage scale could round
|
||||
/// a whole far frame transparent (kelp blinking out at distance).
|
||||
fn source_chains(
|
||||
frames: &[&[u8]],
|
||||
size: u32,
|
||||
settings: &[Settings],
|
||||
layer: u32,
|
||||
) -> (Vec<Vec<u8>>, u32) {
|
||||
let s = settings.get(layer as usize).copied().unwrap_or_default();
|
||||
let edge = if s.native_size.is_power_of_two() && s.native_size < size {
|
||||
s.native_size
|
||||
} else {
|
||||
size
|
||||
};
|
||||
let frame = crate::sprites::resample(pixels, size, edge);
|
||||
(super::mip_chain(&frame, edge, settings, layer, 1).0, edge)
|
||||
let mut sheet = crate::mipmap::Image {
|
||||
width: edge,
|
||||
height: edge * frames.len() as u32,
|
||||
pixels: Vec::with_capacity((edge * edge) as usize * frames.len()),
|
||||
};
|
||||
for frame in frames {
|
||||
let frame = crate::sprites::resample(frame, size, edge);
|
||||
sheet.pixels.extend_from_slice(frame.as_chunks::<4>().0);
|
||||
}
|
||||
let has_transparent = s.has_transparent || sheet.pixels.iter().any(|p| p[3] == 0);
|
||||
let levels = crate::mipmap::generate(
|
||||
sheet,
|
||||
edge.trailing_zeros(),
|
||||
Settings {
|
||||
has_transparent,
|
||||
..s
|
||||
},
|
||||
);
|
||||
let chains = (0..frames.len())
|
||||
.map(|frame| {
|
||||
let mut chain = Vec::new();
|
||||
for level in &levels {
|
||||
let texels = (level.width * level.width) as usize;
|
||||
chain.extend(
|
||||
level.pixels[frame * texels..(frame + 1) * texels]
|
||||
.iter()
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
chain
|
||||
})
|
||||
.collect();
|
||||
(chains, edge)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -572,6 +621,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kelp_frames_share_the_sheet_mips() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root
|
||||
.join("assets/minecraft/textures/block/kelp.png")
|
||||
.exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
for name in ["kelp", "kelp_plant", "seagrass", "tall_seagrass_top"] {
|
||||
sprites
|
||||
.insert(&root, &format!("minecraft:block/{name}"))
|
||||
.unwrap();
|
||||
}
|
||||
sprites.finish();
|
||||
// A level drawn nowhere: no texel passes the cutout's alpha test.
|
||||
let blank = |chain: &[u8], edge: u32| {
|
||||
let mut start = 0;
|
||||
let mut level = edge;
|
||||
let mut blank = Vec::new();
|
||||
while level > 0 {
|
||||
let bytes = (level * level * 4) as usize;
|
||||
let texels = &chain[start..start + bytes];
|
||||
blank.push(texels.as_chunks::<4>().0.iter().all(|p| p[3] < 128));
|
||||
start += bytes;
|
||||
level >>= 1;
|
||||
}
|
||||
blank
|
||||
};
|
||||
let mut blinked = 0;
|
||||
for animation in &sprites.animations {
|
||||
let size = sprites.size;
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|f| animation.pixels(f))
|
||||
.collect();
|
||||
let (chains, edge) = source_chains(&pixels, size, &sprites.mips, animation.layer);
|
||||
for (frame, chain) in chains.iter().enumerate() {
|
||||
// The 1x1 level has no texel square to measure coverage on
|
||||
// (vanilla leaves it unscaled); every larger level shows.
|
||||
let levels = blank(chain, edge);
|
||||
assert!(
|
||||
!levels[..levels.len() - 1].contains(&true),
|
||||
"layer {} frame {frame}: {levels:?}",
|
||||
animation.layer
|
||||
);
|
||||
let (alone, _) = source_chain(pixels[frame], size, &sprites.mips, animation.layer);
|
||||
let alone = blank(&alone, edge);
|
||||
blinked += usize::from(alone[..alone.len() - 1].contains(&true));
|
||||
}
|
||||
}
|
||||
// Mipmapped frame by frame, some kelp frames vanished at the 2x2 level.
|
||||
assert!(blinked > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_matches_f32_mix_but_for_exact_halves() {
|
||||
// The CPU blend this replaced: f32 mix, then rounded. They differ
|
||||
@@ -730,8 +836,11 @@ mod tests {
|
||||
queue.submit([encoder.finish()]);
|
||||
for animation in &animations {
|
||||
let blend = animation.blend(tick);
|
||||
let chain = |f| source_chain(animation.pixels(f), size, &mips, animation.layer);
|
||||
let ((current, edge), (next, _)) = (chain(blend.frame), chain(blend.next));
|
||||
let pixels: Vec<_> = (0..animation.frame_count())
|
||||
.map(|f| animation.pixels(f))
|
||||
.collect();
|
||||
let (chains, edge) = source_chains(&pixels, size, &mips, animation.layer);
|
||||
let (current, next) = (&chains[blend.frame], &chains[blend.next]);
|
||||
let layers = read_back(
|
||||
&device,
|
||||
&queue,
|
||||
@@ -739,7 +848,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
layers[(animation.layer % split) as usize],
|
||||
reference(¤t, &next, blend.millis, edge, size),
|
||||
reference(current, next, blend.millis, edge, size),
|
||||
"layer {} at tick {tick}",
|
||||
animation.layer
|
||||
);
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
use super::Vertex;
|
||||
|
||||
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), energy swirls
|
||||
/// (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14,
|
||||
/// ordinary entities, world text drawn with a depth bias (35,
|
||||
/// `RenderPipelines.TEXT_POLYGON_OFFSET`), breaking cracks (11), translucent
|
||||
/// breaking cracks (12), the boat water mask (13, `RenderPipelines.WATER_MASK`),
|
||||
/// energy swirls (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14,
|
||||
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
|
||||
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
|
||||
/// and translucent item quads (32, drawn without depth writes).
|
||||
const MATERIALS: usize = 8;
|
||||
const MATERIALS: usize = 9;
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
|
||||
@@ -24,17 +25,21 @@ pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
|
||||
pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
|
||||
/// A translucent item with an enchantment glint.
|
||||
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
/// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays):
|
||||
/// drawn like an ordinary entity, pulled toward the camera.
|
||||
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order).
|
||||
fn material(layer: u32) -> usize {
|
||||
match layer {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
ENERGY_SWIRL_OVERLAY => 4,
|
||||
SHADOW_OVERLAY => 5,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 6,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
WATER_MASK_OVERLAY => 4,
|
||||
ENERGY_SWIRL_OVERLAY => 5,
|
||||
SHADOW_OVERLAY => 6,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 7,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 8,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -102,9 +107,10 @@ mod tests {
|
||||
) -> (Vec<Vertex>, [u32; MATERIALS]) {
|
||||
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
|
||||
let mut counts = [0; MATERIALS];
|
||||
for (i, layer) in [0, 11, 12, 13, 34, 14, 15].into_iter().enumerate() {
|
||||
for (i, layer) in [0, 35, 11, 12, 13, 34, 14, 15].into_iter().enumerate() {
|
||||
for quad in entities.as_chunks::<4>().0 {
|
||||
let material = match quad[0].layer {
|
||||
35 => 35,
|
||||
11 => 11,
|
||||
12 => 12,
|
||||
13 => 13,
|
||||
@@ -130,7 +136,7 @@ mod tests {
|
||||
pos: [index as f32, 2.0, 3.0],
|
||||
uv: [index as f32, 5.0],
|
||||
layer: if mixed {
|
||||
[7, 11, 14, 12, 4, 13, 15, 0][index / 4 % 8]
|
||||
[7, 11, 14, 35, 12, 4, 13, 15, 0][index / 4 % 9]
|
||||
} else {
|
||||
0
|
||||
},
|
||||
|
||||
@@ -14,6 +14,12 @@ pub enum Output {
|
||||
}
|
||||
|
||||
impl Output {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pq => "HDR10 (PQ)",
|
||||
Self::ScRgb => "scRGB",
|
||||
}
|
||||
}
|
||||
pub fn format(self) -> wgpu::TextureFormat {
|
||||
match self {
|
||||
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The sunrise/sunset fan (`SkyRenderer.renderSunriseAndSunset`) as the sky
|
||||
//! shader tests it. The fan's 16 triangles depend only on the sunrise alpha
|
||||
//! and the horizon the sun is on, so their vertices are built here once a
|
||||
//! frame instead of in every sky pixel (#3143: with the per-pixel build, four
|
||||
//! `sin`/`cos` per triangle, the sky pass took 10.2 ms at dawn at 1080p on an
|
||||
//! Intel UHD 730 against 0.6 ms by day; now 1.8 ms).
|
||||
//!
|
||||
//! Möller-Trumbore from the camera reduces to triple products: with apex A
|
||||
//! and rim vertices r0, r1, a ray d hits at barycentrics proportional to
|
||||
//! (`[d, r0, r1]`, `[A, d, r1]`, `[A, r0, d]`), each a dot of d with a
|
||||
//! constant normal, and their sum S is the negated determinant. A hit needs
|
||||
//! S on the side of `[A, r0, r1]`, so only the triangles whose rim normals'
|
||||
//! dots change sign there are tested in full.
|
||||
use glam::Vec3;
|
||||
|
||||
pub const TRIANGLES: usize = 16;
|
||||
|
||||
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
|
||||
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
|
||||
/// Then per rim vertex: xyz `r × A`.
|
||||
pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
|
||||
let (apex, rim) = vertices(alpha, sun);
|
||||
let mut out = [[0.0; 4]; 2 * TRIANGLES];
|
||||
for i in 0..TRIANGLES {
|
||||
let (r0, r1) = (rim[i], rim[(i + 1) % TRIANGLES]);
|
||||
out[i] = r0.cross(r1).extend(apex.dot(r0.cross(r1))).to_array();
|
||||
out[TRIANGLES + i] = r0.cross(apex).extend(0.0).to_array();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The fan's apex and rim in camera space, as vanilla builds them: the
|
||||
/// fan turned to the horizon the sun sets on (or rises from), its rim
|
||||
/// tilted by the alpha.
|
||||
fn vertices(alpha: f32, sun: Vec3) -> (Vec3, [Vec3; TRIANGLES]) {
|
||||
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
|
||||
let flip = if -sun.z < 0.0 { 180f32 } else { 0.0 };
|
||||
let (s, c) = (flip + 90.0).to_radians().sin_cos();
|
||||
let rotate_x90 = |v: Vec3| Vec3::new(v.x, -v.z, v.y);
|
||||
let apex = rotate_x90(Vec3::new(-100.0 * s, 100.0 * c, 0.0));
|
||||
let rim = std::array::from_fn(|i| {
|
||||
let a = i as f32 * std::f32::consts::TAU / TRIANGLES as f32;
|
||||
let l = Vec3::new(a.sin() * 120.0, a.cos() * 120.0, -a.cos() * 40.0 * alpha);
|
||||
rotate_x90(Vec3::new(l.x * c - l.y * s, l.x * s + l.y * c, l.z))
|
||||
});
|
||||
(apex, rim)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// terrain.wgsl's `sunrise_weight` over the normals.
|
||||
fn weight(normals: &[[f32; 4]; 2 * TRIANGLES], d: Vec3) -> f32 {
|
||||
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
|
||||
let mut best = 0.0f32;
|
||||
let mut k0 = d.dot(n(TRIANGLES));
|
||||
for i in 0..TRIANGLES {
|
||||
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
|
||||
let w = normals[i][3];
|
||||
if k0 * w <= 0.0 && k1 * w >= 0.0 {
|
||||
let apex = d.dot(n(i));
|
||||
let s = apex + k1 - k0;
|
||||
if s.abs() >= 1e-6
|
||||
&& s * w > 0.0
|
||||
&& apex * s >= 0.0
|
||||
&& k1 * s >= 0.0
|
||||
&& k0 * s <= 0.0
|
||||
{
|
||||
best = best.max(apex / s);
|
||||
}
|
||||
}
|
||||
k0 = k1;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
|
||||
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
|
||||
let (apex, rim) = vertices(alpha, sun);
|
||||
let mut best = 0.0f32;
|
||||
for i in 0..TRIANGLES {
|
||||
let e1 = rim[i] - apex;
|
||||
let e2 = rim[(i + 1) % TRIANGLES] - apex;
|
||||
let pv = d.cross(e2);
|
||||
let det = e1.dot(pv);
|
||||
if det.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let tv = -apex;
|
||||
let u = tv.dot(pv) / det;
|
||||
let qv = tv.cross(e1);
|
||||
let v = d.dot(qv) / det;
|
||||
let t = e2.dot(qv) / det;
|
||||
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
|
||||
best = best.max(1.0 - u - v);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
|
||||
let mut hits = 0;
|
||||
for (alpha, sun_z) in [(1.0f32, -0.2f32), (0.6, 0.3), (0.05, -0.9), (0.9, 0.05)] {
|
||||
let sun = Vec3::new(0.0, (1.0 - sun_z * sun_z).sqrt(), sun_z);
|
||||
let normals = normals(alpha, sun);
|
||||
for yaw in 0..180 {
|
||||
for pitch in -45..=45 {
|
||||
let (yaw, pitch) = (
|
||||
(yaw as f32 * 2.0).to_radians(),
|
||||
(pitch as f32 * 2.0).to_radians(),
|
||||
);
|
||||
let d = Vec3::new(
|
||||
yaw.cos() * pitch.cos(),
|
||||
pitch.sin(),
|
||||
yaw.sin() * pitch.cos(),
|
||||
);
|
||||
let (got, want) = (weight(&normals, d), reference(alpha, sun, d));
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"{alpha} {sun_z} {d}: {got} vs {want}"
|
||||
);
|
||||
hits += usize::from(want > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The fan covers part of the sky, not none of it.
|
||||
assert!(hits > 1000, "{hits}");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,44 @@
|
||||
//! Resolution scale and FXAA (Horizon settings): the world renders into its
|
||||
//! Resolution scale, FXAA and TAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With neither on,
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With none on,
|
||||
//! the world renders straight to the screen.
|
||||
//!
|
||||
//! TAA jitters the world's projection by a sub-pixel offset each frame and blends
|
||||
//! the frame into a history reprojected through the depth buffer: its motion
|
||||
//! vectors are the camera's, so what moves on its own leans on the neighbourhood
|
||||
//! clamp alone.
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec2};
|
||||
|
||||
/// The TAA history format: 8 bits would band as frames accumulate.
|
||||
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
color: wgpu::TextureView,
|
||||
depth: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
taa: Option<History>,
|
||||
}
|
||||
|
||||
/// TAA's two history textures at the output size (the world's, times the upscale), written
|
||||
/// in turn: `resolve[i]` writes `history[i]` from the world and `history[1 - i]`; `show[i]`
|
||||
/// copies `history[i]` to the screen.
|
||||
struct History {
|
||||
size: [u32; 2],
|
||||
history: [wgpu::TextureView; 2],
|
||||
resolve: [wgpu::BindGroup; 2],
|
||||
show: [wgpu::BindGroup; 2],
|
||||
}
|
||||
|
||||
/// `world_post.wgsl`'s `Taa`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct TaaUniform {
|
||||
/// This frame's unjittered clip space to the previous frame's.
|
||||
reproject: [[f32; 4]; 4],
|
||||
/// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
|
||||
jitter: [f32; 4],
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
@@ -19,7 +50,21 @@ pub struct Post {
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
pub taa: bool,
|
||||
/// Output pixels per rendered pixel while TAA is on (1: native): the world renders
|
||||
/// this much smaller and TAA rebuilds it at the resolution scale's size.
|
||||
pub upscale: f32,
|
||||
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
|
||||
taa_on: bool,
|
||||
target: Option<WorldTarget>,
|
||||
taa_layout: wgpu::BindGroupLayout,
|
||||
taa_pipeline: wgpu::RenderPipeline,
|
||||
taa_uniform: wgpu::Buffer,
|
||||
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
|
||||
taa_frame: u32,
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
taa_previous: Option<(DMat4, DVec3)>,
|
||||
taa_this_frame: TaaUniform,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -49,15 +94,55 @@ impl Post {
|
||||
},
|
||||
],
|
||||
});
|
||||
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str| {
|
||||
let taa_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
layout: Some(&pipeline_layout),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_post"),
|
||||
@@ -88,41 +173,102 @@ impl Post {
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
copy: pipeline("fs_copy"),
|
||||
fxaa: pipeline("fs_fxaa"),
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
taa_pipeline: pipeline("fs_taa", &taa_pipeline_layout, HISTORY_FORMAT),
|
||||
taa_uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
layout,
|
||||
taa_layout,
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
taa: false,
|
||||
upscale: 1.0,
|
||||
taa_on: false,
|
||||
target: None,
|
||||
taa_frame: 0,
|
||||
taa_previous: None,
|
||||
taa_this_frame: bytemuck::Zeroable::zeroed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
let upscale = if self.taa_on { self.upscale } else { 1.0 };
|
||||
(
|
||||
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
|
||||
self.taa_on = on && self.taa;
|
||||
if !self.taa_on {
|
||||
self.taa_previous = None;
|
||||
return Mat4::IDENTITY;
|
||||
}
|
||||
self.taa_frame = self.taa_frame.wrapping_add(1);
|
||||
let (size, _) = self.sizes(window);
|
||||
// Halton (2, 3) over 8 frames per output pixel (FSR's count), in rendered
|
||||
// pixels around the centre.
|
||||
let phases = (8.0 * self.upscale * self.upscale).ceil() as u32;
|
||||
let i = self.taa_frame % phases + 1;
|
||||
let offset = Vec2::new(halton(i, 2), halton(i, 3)) - 0.5;
|
||||
let jitter = offset * 2.0 / Vec2::new(size[0] as f32, size[1] as f32);
|
||||
let current = view_proj.as_dmat4();
|
||||
let (reproject, reset) = match self.taa_previous {
|
||||
Some((previous, previous_camera)) => (
|
||||
reprojection(previous, previous_camera, current, camera),
|
||||
0.0,
|
||||
),
|
||||
None => (DMat4::IDENTITY, 1.0),
|
||||
};
|
||||
self.taa_previous = Some((current, camera));
|
||||
self.taa_this_frame = TaaUniform {
|
||||
reproject: reproject.as_mat4().to_cols_array_2d(),
|
||||
jitter: [jitter.x, jitter.y, reset, 0.0],
|
||||
};
|
||||
Mat4::from_translation(jitter.extend(0.0))
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen.
|
||||
/// `always`: a target even at scale 1 without FXAA (a shader pack reads it).
|
||||
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
always: bool,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias && !always {
|
||||
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let make_tex = |label, format, usage| {
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
|
||||
{
|
||||
let make_tex = |label, size: [u32; 2], format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent,
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
@@ -131,25 +277,68 @@ impl Post {
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = make_tex(
|
||||
"Scaled world",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
// TEXTURE_BINDING: occlusion culling reads it.
|
||||
let sampled =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = make_tex("Scaled world", size, self.format, sampled)
|
||||
.create_view(&Default::default());
|
||||
// TEXTURE_BINDING: occlusion culling and TAA read it.
|
||||
let depth = make_tex(
|
||||
"Scaled world depth",
|
||||
size,
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
sampled,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
let taa = history_size.map(|output| {
|
||||
let history = [0, 1].map(|_| {
|
||||
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
|
||||
.create_view(&Default::default())
|
||||
});
|
||||
let resolve = [0, 1].map(|i| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: &self.taa_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&history[1 - i]),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.taa_uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
let show = [0, 1].map(|i| self.bind(device, &history[i]));
|
||||
History {
|
||||
size: output,
|
||||
history,
|
||||
resolve,
|
||||
show,
|
||||
}
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
color,
|
||||
depth,
|
||||
group,
|
||||
taa,
|
||||
});
|
||||
}
|
||||
self.target
|
||||
@@ -180,10 +369,43 @@ impl Post {
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw the scaled world onto `screen`.
|
||||
pub fn finish(&self, encoder: &mut wgpu::CommandEncoder, screen: &wgpu::TextureView) {
|
||||
/// Draw the scaled world onto `screen`, through TAA when this frame's `jitter` had it on.
|
||||
pub fn finish(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
screen: &wgpu::TextureView,
|
||||
) {
|
||||
let Some(target) = &self.target else { return };
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
queue.write_buffer(
|
||||
&self.taa_uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&self.taa_this_frame),
|
||||
);
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.taa_pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
self.finish_with(encoder, screen, &taa.show[i]);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
@@ -216,3 +438,51 @@ impl Post {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// This frame's clip space to the previous frame's, for camera-relative projections:
|
||||
/// a point `camera - previous_camera` further from the previous camera than from this one.
|
||||
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
|
||||
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
|
||||
}
|
||||
|
||||
/// The radical inverse of `i` in `base`: a low-discrepancy sequence in 0..1.
|
||||
fn halton(mut i: u32, base: u32) -> f32 {
|
||||
let (mut f, mut r) = (1.0, 0.0);
|
||||
while i > 0 {
|
||||
f /= base as f32;
|
||||
r += f * (i % base) as f32;
|
||||
i /= base;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reprojection_finds_where_a_block_was_last_frame() {
|
||||
let projection = DMat4::perspective_rh(1.2, 16.0 / 9.0, 1024.0, 0.05);
|
||||
let previous = projection * DMat4::from_rotation_y(0.3);
|
||||
let current = projection * DMat4::from_rotation_y(0.35);
|
||||
let (previous_camera, camera) = (DVec3::new(10.0, 64.0, 5.0), DVec3::new(10.5, 64.2, 4.0));
|
||||
let block = DVec3::new(14.0, 66.0, -8.0);
|
||||
let clip = |m: DMat4, eye: DVec3| m * (block - eye).extend(1.0);
|
||||
let now = clip(current, camera);
|
||||
let then = reprojection(previous, previous_camera, current, camera) * (now / now.w);
|
||||
let expected = clip(previous, previous_camera);
|
||||
assert!((then.truncate() / then.w - expected.truncate() / expected.w).length() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_stays_inside_the_pixel() {
|
||||
for i in 1..=8 {
|
||||
let (x, y) = (halton(i, 2), halton(i, 3));
|
||||
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
|
||||
}
|
||||
assert_eq!(
|
||||
[halton(1, 2), halton(2, 2), halton(3, 2)],
|
||||
[0.5, 0.25, 0.75]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -96,9 +96,9 @@ impl PresentMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphics API the renderer runs on. Only Windows has a choice: the
|
||||
/// other systems always use their one backend (Vulkan on Linux, Metal on
|
||||
/// macOS). Applies at the next start.
|
||||
/// The graphics API the renderer runs on. Windows picks Vulkan, DirectX 12 or
|
||||
/// OpenGL; Linux picks Vulkan or OpenGL; macOS always uses its one backend
|
||||
/// (Metal). Applies at the next start.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GraphicsApi {
|
||||
/// Vulkan, then DirectX 12 (Windows), then OpenGL when neither has a surface, adapter or device.
|
||||
@@ -116,6 +116,26 @@ impl GraphicsApi {
|
||||
GraphicsApi::Dx12,
|
||||
GraphicsApi::OpenGl,
|
||||
];
|
||||
/// The APIs this system offers, in the order the setting cycles through
|
||||
/// them: DirectX 12 is Windows' third backend, and macOS has no choice.
|
||||
pub fn choices() -> &'static [GraphicsApi] {
|
||||
const WINDOWS: [GraphicsApi; 4] = [
|
||||
GraphicsApi::Auto,
|
||||
GraphicsApi::Vulkan,
|
||||
GraphicsApi::Dx12,
|
||||
GraphicsApi::OpenGl,
|
||||
];
|
||||
const LINUX: [GraphicsApi; 3] =
|
||||
[GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::OpenGl];
|
||||
const MACOS: [GraphicsApi; 1] = [GraphicsApi::Auto];
|
||||
if cfg!(windows) {
|
||||
&WINDOWS
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&MACOS
|
||||
} else {
|
||||
&LINUX
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GraphicsApi::Auto => "auto",
|
||||
@@ -200,6 +220,61 @@ impl FarDetail {
|
||||
}
|
||||
}
|
||||
|
||||
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
|
||||
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Upscaling {
|
||||
#[default]
|
||||
Native,
|
||||
Quality,
|
||||
Balanced,
|
||||
Performance,
|
||||
UltraPerformance,
|
||||
}
|
||||
impl Upscaling {
|
||||
pub const ALL: [Upscaling; 5] = [
|
||||
Upscaling::Native,
|
||||
Upscaling::Quality,
|
||||
Upscaling::Balanced,
|
||||
Upscaling::Performance,
|
||||
Upscaling::UltraPerformance,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Upscaling::Native => "native",
|
||||
Upscaling::Quality => "quality",
|
||||
Upscaling::Balanced => "balanced",
|
||||
Upscaling::Performance => "performance",
|
||||
Upscaling::UltraPerformance => "ultraPerformance",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Upscaling::Native => "Native",
|
||||
Upscaling::Quality => "Quality",
|
||||
Upscaling::Balanced => "Balanced",
|
||||
Upscaling::Performance => "Performance",
|
||||
Upscaling::UltraPerformance => "Ultra Performance",
|
||||
}
|
||||
}
|
||||
/// Output pixels per rendered pixel, along each axis.
|
||||
pub fn ratio(self) -> f32 {
|
||||
match self {
|
||||
Upscaling::Native => 1.0,
|
||||
Upscaling::Quality => 1.5,
|
||||
Upscaling::Balanced => 1.7,
|
||||
Upscaling::Performance => 2.0,
|
||||
Upscaling::UltraPerformance => 3.0,
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|u| u.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
static REDUCED_SUGGESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the settings suggest Reduced far detail: set when the renderer
|
||||
/// starts, on the GPUs `lod_capped` names (far terrain slows them most).
|
||||
@@ -262,6 +337,10 @@ pub struct Graphics {
|
||||
pub resolution_scale: u32,
|
||||
/// FXAA over the world.
|
||||
pub fxaa: bool,
|
||||
/// TAA over the world (FXAA is off while it is on).
|
||||
pub taa: bool,
|
||||
/// TAA upscaling; anything but `Native` comes with `taa` on.
|
||||
pub upscaling: Upscaling,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
@@ -299,6 +378,8 @@ impl Default for Graphics {
|
||||
overlay: Overlay::Off,
|
||||
resolution_scale: 100,
|
||||
fxaa: false,
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
hdr: false,
|
||||
hdr_paper_white: 200,
|
||||
hdr_peak: 1000,
|
||||
@@ -361,6 +442,8 @@ impl Graphics {
|
||||
"workers" => g.workers = number(g.workers).min(64),
|
||||
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
|
||||
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
|
||||
@@ -381,7 +464,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -406,6 +489,8 @@ impl Graphics {
|
||||
},
|
||||
self.resolution_scale,
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
@@ -486,6 +571,21 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_api_choice_matches_the_system() {
|
||||
let choices = GraphicsApi::choices();
|
||||
if cfg!(windows) {
|
||||
assert_eq!(choices, GraphicsApi::ALL);
|
||||
} else if cfg!(target_os = "macos") {
|
||||
assert_eq!(choices, [GraphicsApi::Auto]);
|
||||
} else {
|
||||
assert_eq!(
|
||||
choices,
|
||||
[GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::OpenGl]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let g = Graphics {
|
||||
@@ -506,6 +606,8 @@ mod tests {
|
||||
overlay: Overlay::Graph,
|
||||
resolution_scale: 75,
|
||||
fxaa: true,
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset},
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
settings::{Opt, Settings},
|
||||
@@ -36,6 +36,7 @@ pub enum H {
|
||||
Overlay,
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
Hdr,
|
||||
HdrPaperWhite,
|
||||
HdrPeak,
|
||||
@@ -221,7 +222,12 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::Antialiasing,
|
||||
Medium,
|
||||
"FXAA smooths jagged edges of the world; slightly softer image.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
|
||||
),
|
||||
h(
|
||||
H::Upscaling,
|
||||
High,
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::Hdr,
|
||||
@@ -276,7 +282,7 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::GraphicsApi,
|
||||
None,
|
||||
"Vulkan, or DirectX 12 where Vulkan fails. Next start.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
|
||||
),
|
||||
h(
|
||||
H::UploadBudget,
|
||||
@@ -291,8 +297,8 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
),
|
||||
],
|
||||
};
|
||||
// Only Windows has more than one graphics API.
|
||||
if !cfg!(windows) {
|
||||
// Only Windows and Linux have more than one graphics API.
|
||||
if cfg!(target_os = "macos") {
|
||||
rows.retain(
|
||||
|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::GraphicsApi)),
|
||||
);
|
||||
@@ -303,9 +309,13 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
|
||||
/// Horizon's graphics screens supplement the vanilla options on Video Settings.
|
||||
fn extra_rows(level: Level) -> Vec<Row> {
|
||||
/// Upscaling is only there while TAA is, which does it.
|
||||
fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
|
||||
let mut rows = rows(level);
|
||||
rows.retain(|row| !matches!(row, Row::Setting(s) if matches!(s.item, Item::Vanilla(_))));
|
||||
if !settings.horizon.taa {
|
||||
rows.retain(|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::Upscaling)));
|
||||
}
|
||||
// Drop headings whose settings were all on the parent screens.
|
||||
let mut has_setting = false;
|
||||
rows.reverse();
|
||||
@@ -383,7 +393,15 @@ impl H {
|
||||
n => labelled("Worker Threads", n),
|
||||
},
|
||||
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
|
||||
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
|
||||
H::Antialiasing => labelled(
|
||||
"Anti-aliasing",
|
||||
match (g.fxaa, g.taa) {
|
||||
(_, true) => "TAA",
|
||||
(true, false) => "FXAA",
|
||||
(false, false) => "Off",
|
||||
},
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
|
||||
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
|
||||
@@ -441,10 +459,25 @@ impl H {
|
||||
H::Weather => g.weather = !g.weather,
|
||||
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
|
||||
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
|
||||
H::Antialiasing => g.fxaa = !g.fxaa,
|
||||
// Off, FXAA, TAA.
|
||||
H::Antialiasing => {
|
||||
let i = if g.taa { 2 } else { usize::from(g.fxaa) };
|
||||
let i = (i + if back { 2 } else { 1 }) % 3;
|
||||
(g.fxaa, g.taa) = (i == 1, i == 2);
|
||||
if !g.taa {
|
||||
g.upscaling = Upscaling::Native;
|
||||
}
|
||||
}
|
||||
// Upscaling is TAA's: picking a ratio turns it on.
|
||||
H::Upscaling => {
|
||||
g.upscaling = step(&Upscaling::ALL, g.upscaling, back);
|
||||
if g.upscaling != Upscaling::Native {
|
||||
(g.fxaa, g.taa) = (false, true);
|
||||
}
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::GraphicsApi => g.api = step(&GraphicsApi::ALL, g.api, back),
|
||||
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
|
||||
H::Pacing => {
|
||||
g.pacing = match g.pacing {
|
||||
Pacing::Efficient => Pacing::Precise,
|
||||
@@ -481,7 +514,6 @@ impl Item {
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::Antialiasing) => g.fxaa,
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
|
||||
@@ -570,6 +602,7 @@ impl Item {
|
||||
pub enum Action {
|
||||
ButtonClick,
|
||||
Changed,
|
||||
SliderDrag,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
Done,
|
||||
@@ -603,8 +636,8 @@ impl GraphicsUi {
|
||||
fn bounds(h: f32) -> (f32, f32) {
|
||||
(STYLE.band, h - STYLE.band)
|
||||
}
|
||||
fn content_height(level: Level) -> f32 {
|
||||
extra_rows(level).iter().fold(4.0, |height, row| {
|
||||
fn content_height(level: Level, s: &Settings) -> f32 {
|
||||
extra_rows(level, s).iter().fold(4.0, |height, row| {
|
||||
height
|
||||
+ match row {
|
||||
Row::Header(_) => STYLE.header_height,
|
||||
@@ -616,17 +649,18 @@ impl GraphicsUi {
|
||||
fn layout(
|
||||
&mut self,
|
||||
level: Level,
|
||||
s: &Settings,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) -> (Vec<Widget>, Vec<(f32, &'static str)>, f32) {
|
||||
let content = Self::content_height(level);
|
||||
let content = Self::content_height(level, s);
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, _) = Self::bounds(h);
|
||||
let x = STYLE.row_x(w);
|
||||
let mut y = top + 4.0 - self.scroll;
|
||||
let mut widgets = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
for row in extra_rows(level) {
|
||||
for row in extra_rows(level, s) {
|
||||
match row {
|
||||
Row::Header(name) => {
|
||||
headers.push((y, name));
|
||||
@@ -680,7 +714,7 @@ impl GraphicsUi {
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let (widgets, headers, content) = self.layout(level, w, h);
|
||||
let (widgets, headers, content) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
STYLE.background(batch, atlas, top, bottom, in_world);
|
||||
let shown = |y: f32, height: f32| y >= top && y + height <= bottom;
|
||||
@@ -690,7 +724,7 @@ impl GraphicsUi {
|
||||
STYLE.header(batch, atlas, &Text::literal(name), x, y);
|
||||
}
|
||||
}
|
||||
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level)
|
||||
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
|
||||
.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
Row::Setting(s) => Some((s.cost, s.text)),
|
||||
@@ -791,7 +825,7 @@ impl GraphicsUi {
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Action> {
|
||||
let (widgets, _, _) = self.layout(level, w, h);
|
||||
let (widgets, _, _) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
if drag {
|
||||
if let Some(item) = self.dragging
|
||||
@@ -800,7 +834,7 @@ impl GraphicsUi {
|
||||
.find(|wd| matches!(wd.target, Target::Item(i) if i == item))
|
||||
{
|
||||
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
|
||||
return vec![Action::Changed];
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -822,11 +856,11 @@ impl GraphicsUi {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
|
||||
vec![Action::SliderDrag]
|
||||
} else {
|
||||
item.cycle(s, right, framebuffer);
|
||||
return vec![Action::ButtonClick, Action::Changed];
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
}
|
||||
vec![Action::Changed]
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -835,8 +869,8 @@ impl GraphicsUi {
|
||||
pub fn release(&mut self) -> bool {
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
pub fn scroll(&mut self, level: Level, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level);
|
||||
pub fn scroll(&mut self, level: Level, s: &Settings, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level, s);
|
||||
self.clamp_scroll(h, content);
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(h, content);
|
||||
@@ -852,7 +886,7 @@ mod tests {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 320.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let button = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Button(_)))
|
||||
@@ -897,7 +931,7 @@ mod tests {
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::Changed]
|
||||
vec![Action::SliderDrag]
|
||||
);
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
@@ -909,7 +943,7 @@ mod tests {
|
||||
true,
|
||||
false
|
||||
),
|
||||
vec![Action::Changed]
|
||||
vec![Action::SliderDrag]
|
||||
);
|
||||
assert!(ui.release());
|
||||
assert!(!ui.release());
|
||||
@@ -917,45 +951,47 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scrolling_stops_at_both_limits_before_layout() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
let h = 2.0 * STYLE.band + 10.0;
|
||||
let (top, bottom) = GraphicsUi::bounds(h);
|
||||
let max = GraphicsUi::content_height(level) - (bottom - top);
|
||||
let max = GraphicsUi::content_height(level, &s) - (bottom - top);
|
||||
assert!(max > 4.5);
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, h, 1000.0);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, h, -1000.0);
|
||||
ui.scroll(level, &s, h, -1000.0);
|
||||
assert_eq!(ui.scroll, max);
|
||||
}
|
||||
ui.scroll(level, h, 0.25);
|
||||
ui.scroll(level, &s, h, 0.25);
|
||||
assert_eq!(ui.scroll, max - 4.5);
|
||||
ui.scroll(level, h, 1000.0);
|
||||
ui.scroll(level, h, -0.25);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
ui.scroll(level, &s, h, -0.25);
|
||||
assert_eq!(ui.scroll, 4.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_clamps_before_positioning_graphics_rows() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
ui.scroll(level, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
ui.scroll(level, &s, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
assert!(ui.scroll > 0.0);
|
||||
let (widgets, headers, _) = ui.layout(level, 480.0, 4000.0);
|
||||
let (widgets, headers, _) = ui.layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
if let Some((y, _)) = headers.first() {
|
||||
assert_eq!(*y, STYLE.band + 4.0);
|
||||
}
|
||||
let (expected, _, _) = GraphicsUi::default().layout(level, 480.0, 4000.0);
|
||||
let (expected, _, _) = GraphicsUi::default().layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(
|
||||
widgets.iter().map(|w| w.y).collect::<Vec<_>>(),
|
||||
expected.iter().map(|w| w.y).collect::<Vec<_>>()
|
||||
);
|
||||
ui.scroll(level, 4000.0, -1000.0);
|
||||
ui.scroll(level, &s, 4000.0, -1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -963,7 +999,7 @@ mod tests {
|
||||
#[test]
|
||||
fn classic_submenus_only_add_horizon_settings_without_empty_groups() {
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let rows = extra_rows(level);
|
||||
let rows = extra_rows(level, &Settings::default());
|
||||
assert!(rows.iter().any(|row| matches!(row, Row::Setting(_))));
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
match row {
|
||||
@@ -1051,6 +1087,37 @@ mod tests {
|
||||
assert_eq!(s.horizon.upload_budget_kib, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn antialiasing_cycles_off_fxaa_taa() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::Antialiasing);
|
||||
for label in ["FXAA", "TAA", "Off"] {
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(item.label(&s).plain(), format!("Anti-aliasing: {label}"));
|
||||
}
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!((s.horizon.fxaa, s.horizon.taa), (false, true));
|
||||
|
||||
// Upscaling needs TAA: a ratio turns it on, leaving TAA goes back to Native.
|
||||
s.horizon.taa = false;
|
||||
let upscaling = Item::Horizon(H::Upscaling);
|
||||
upscaling.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(upscaling.label(&s).plain(), "Upscaling: Quality");
|
||||
assert!(s.horizon.taa);
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(s.horizon.upscaling, Upscaling::Native);
|
||||
|
||||
// The Upscaling row is only on the screen while TAA is on.
|
||||
let shown = |s: &Settings| {
|
||||
extra_rows(Level::Advanced, s)
|
||||
.iter()
|
||||
.any(|row| matches!(row, Row::Setting(r) if r.item == upscaling))
|
||||
};
|
||||
assert!(!shown(&s));
|
||||
s.horizon.taa = true;
|
||||
assert!(shown(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
@@ -1067,7 +1134,7 @@ mod tests {
|
||||
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
|
||||
@@ -1001,6 +1001,35 @@ impl HudBatch {
|
||||
y: f32,
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, false)
|
||||
}
|
||||
/// `text_styled` for text drawn in the world (`TextRenderable.render`
|
||||
/// with `flat` false): each quad's `depth` is its z in text units, the
|
||||
/// glyphs `Font.SHADOW_DEPTH` in front of their shadow, a bold copy 0.001
|
||||
/// in front of its glyph and the lines 0.01 (`BakedSheetGlyph`), so they
|
||||
/// do not fight over the same pixels.
|
||||
pub fn text_styled_world(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, true)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn styled_text(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
world: bool,
|
||||
) -> f32 {
|
||||
// StringDecomposer.iterateFormatted inside Font.drawInternal:
|
||||
// `§` codes inside a span restyle what follows and are not drawn.
|
||||
@@ -1011,7 +1040,13 @@ impl HudBatch {
|
||||
let alpha = (color >> 24) as u8;
|
||||
let mut glyphs = Vec::new();
|
||||
let mut cursor = x;
|
||||
let mut previous = None;
|
||||
for (i, (ch, style)) in text.chars().enumerate() {
|
||||
// Font.PreparedTextBuilder.accept: lines start 1 left of a
|
||||
// sequence's first character. Language.getVisualOrder makes a
|
||||
// sequence of each run of one style, which world text has.
|
||||
let first = i == 0 || world && previous != Some(style);
|
||||
previous = Some(style);
|
||||
// The style's font (`Style.getFont`), the default one unless set.
|
||||
let mut g = font.glyph_styled(ch, style);
|
||||
// Font.StringRenderOutput.accept: obfuscated characters draw a
|
||||
@@ -1032,7 +1067,7 @@ impl HudBatch {
|
||||
style.is_italic(),
|
||||
style.underlined == Some(true),
|
||||
style.strikethrough == Some(true),
|
||||
i == 0,
|
||||
first,
|
||||
advance,
|
||||
));
|
||||
cursor += advance;
|
||||
@@ -1040,19 +1075,25 @@ impl HudBatch {
|
||||
self.patches.extend(font.take_patches());
|
||||
let white = font.white();
|
||||
let passes: &[bool] = if shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the shadow's colour.
|
||||
let shade = |c: u32| {
|
||||
let b = c.to_le_bytes();
|
||||
rgba(
|
||||
(f32::from(b[0]) * 0.25) as u8,
|
||||
(f32::from(b[1]) * 0.25) as u8,
|
||||
(f32::from(b[2]) * 0.25) as u8,
|
||||
b[3],
|
||||
)
|
||||
};
|
||||
for &is_shadow in passes {
|
||||
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
|
||||
// In the world a glyph whose shadow is 0 has none (hasShadow).
|
||||
let has_shadow = shadow && !(world && shade(c) == 0);
|
||||
if is_shadow && !has_shadow {
|
||||
continue;
|
||||
}
|
||||
let (dx, col) = if is_shadow {
|
||||
let b = c.to_le_bytes();
|
||||
(
|
||||
g.offset,
|
||||
rgba(
|
||||
(f32::from(b[0]) * 0.25) as u8,
|
||||
(f32::from(b[1]) * 0.25) as u8,
|
||||
(f32::from(b[2]) * 0.25) as u8,
|
||||
b[3],
|
||||
),
|
||||
)
|
||||
(g.offset, shade(c))
|
||||
} else {
|
||||
(0.0, c)
|
||||
};
|
||||
@@ -1092,17 +1133,31 @@ impl HudBatch {
|
||||
}
|
||||
None => {}
|
||||
};
|
||||
let base = if has_shadow && !is_shadow { 0.03 } else { 0.0 };
|
||||
let lift = |batch: &mut Self, start: usize, z: f32| {
|
||||
if world {
|
||||
for v in &mut batch.vertices[start..] {
|
||||
v.depth = z;
|
||||
}
|
||||
}
|
||||
};
|
||||
let start = self.vertices.len();
|
||||
draw(self, 0.0);
|
||||
lift(self, start, base);
|
||||
if bold {
|
||||
let start = self.vertices.len();
|
||||
draw(self, g.offset);
|
||||
lift(self, start, base + 0.001);
|
||||
}
|
||||
let x0 = if first { px - 1.0 } else { px };
|
||||
let start = self.vertices.len();
|
||||
if strike {
|
||||
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
|
||||
}
|
||||
if underline {
|
||||
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
|
||||
}
|
||||
lift(self, start, base + 0.01);
|
||||
}
|
||||
}
|
||||
cursor - x
|
||||
|
||||
@@ -59,13 +59,7 @@ pub fn panels(
|
||||
translate("effect.duration.infinite").unwrap_or_else(|| "∞".to_owned())
|
||||
} else {
|
||||
// StringUtil.formatTickDuration uses the level's current rate.
|
||||
let seconds = (effect.duration as f32 / tick_rate) as i32;
|
||||
let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
|
||||
if hours > 0 {
|
||||
format!("{hours:02}:{minutes:02}:{seconds:02}")
|
||||
} else {
|
||||
format!("{minutes:02}:{seconds:02}")
|
||||
}
|
||||
crate::brewing::format_tick_duration(effect.duration, tick_rate)
|
||||
};
|
||||
let width = max_width.min(39 + text_width(&name).max(text_width(&duration)));
|
||||
EffectPanel {
|
||||
|
||||
@@ -570,6 +570,15 @@ impl OptionsFile {
|
||||
self.values[i] = V::Int(p as i32);
|
||||
}
|
||||
}
|
||||
/// `Options.modelCustomisation`: the PlayerModelPart bits the player shows
|
||||
/// (cape first, as `modelPart_*` is in `MODEL_PARTS`).
|
||||
pub fn model_customisation(&self) -> u8 {
|
||||
MODEL_PARTS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, part)| self.bool(&format!("modelPart_{part}")))
|
||||
.fold(0, |bits, (bit, _)| bits | 1 << bit)
|
||||
}
|
||||
/// Key mapping value by mapping name.
|
||||
pub fn key_binding(&self, name: &str) -> String {
|
||||
self.string(&format!("key_{name}"))
|
||||
|
||||
@@ -45,6 +45,16 @@ pub(crate) enum Kind {
|
||||
Slider(f32),
|
||||
Cycle,
|
||||
}
|
||||
/// `PlayerModelPart` keys, in vanilla's bit order (`modelPart_*` in options.txt).
|
||||
const MODEL_PART_KEYS: [&str; 7] = [
|
||||
"modelPart_cape",
|
||||
"modelPart_jacket",
|
||||
"modelPart_left_sleeve",
|
||||
"modelPart_right_sleeve",
|
||||
"modelPart_left_pants_leg",
|
||||
"modelPart_right_pants_leg",
|
||||
"modelPart_hat",
|
||||
];
|
||||
/// The Accessibility Settings page:
|
||||
/// `AccessibilityOptionsScreen.options`, with the Controls link inserted separately.
|
||||
pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
@@ -97,9 +107,15 @@ impl Opt {
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::DistortionEffects
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::RotateWithMinecarts
|
||||
// Saved, but no Horizon system reads them yet: no Realms, no
|
||||
// friends list, no directional audio, no now-playing toast.
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::SharePresence
|
||||
| Opt::DirectionalAudio
|
||||
| Opt::MusicToast
|
||||
// Saved, but nothing in rendering reads them yet.
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
@@ -128,6 +144,13 @@ impl Opt {
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
|
||||
Opt::AllowServerListing => "allowServerListing",
|
||||
Opt::RealmsNotifications => "realmsNotifications",
|
||||
Opt::InGameNotification => "inGameNotification",
|
||||
Opt::SharePresence => "sharePresence",
|
||||
Opt::DirectionalAudio => "directionalAudio",
|
||||
Opt::MusicToast => "musicToast",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -225,6 +248,22 @@ impl Opt {
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadMenuNav => "horizon.options.menu_navigation",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
"options.modelPart.jacket",
|
||||
"options.modelPart.left_sleeve",
|
||||
"options.modelPart.right_sleeve",
|
||||
"options.modelPart.left_pants_leg",
|
||||
"options.modelPart.right_pants_leg",
|
||||
"options.modelPart.hat",
|
||||
][i],
|
||||
Opt::MainHand => "options.mainHand",
|
||||
Opt::AllowServerListing => "options.allowServerListing",
|
||||
Opt::RealmsNotifications => "options.realmsNotifications.button",
|
||||
Opt::InGameNotification => "options.inGameNotification",
|
||||
Opt::SharePresence => "options.sharePresence",
|
||||
Opt::DirectionalAudio => "options.directionalAudio",
|
||||
Opt::MusicToast => "options.musicToast",
|
||||
}
|
||||
}
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
@@ -460,9 +499,29 @@ impl Opt {
|
||||
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
|
||||
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
|
||||
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
|
||||
Opt::HideMatchedNames | Opt::OnlySecureChat | Opt::SaveChatDrafts => {
|
||||
on_off(s.file.bool(self.file_key().unwrap()))
|
||||
}
|
||||
Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::ModelPart(_)
|
||||
| Opt::AllowServerListing
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::DirectionalAudio => on_off(s.file.bool(self.file_key().unwrap())),
|
||||
Opt::MainHand => tr(if s.main_hand_left {
|
||||
"options.mainHand.left"
|
||||
} else {
|
||||
"options.mainHand.right"
|
||||
}),
|
||||
Opt::SharePresence => tr([
|
||||
"options.sharePresence.none",
|
||||
"options.sharePresence.limited",
|
||||
"options.sharePresence.all",
|
||||
][s.file.int("sharePresence").clamp(0, 2) as usize]),
|
||||
Opt::MusicToast => tr([
|
||||
"options.musicToast.never",
|
||||
"options.musicToast.pauseMenu",
|
||||
"options.musicToast.pauseMenuAndToast",
|
||||
][s.file.int("musicToast").clamp(0, 2) as usize]),
|
||||
Opt::NarratorHotkey => on_off(s.file.bool("narratorHotkey")),
|
||||
Opt::RotateWithMinecarts => on_off(s.file.bool("rotateWithMinecart")),
|
||||
Opt::HighContrastBlockOutlines => on_off(s.file.bool("highContrastBlockOutline")),
|
||||
@@ -579,12 +638,14 @@ impl Opt {
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v).powi(2)),
|
||||
),
|
||||
Opt::DamageTilt | Opt::PanoramaSpeed | Opt::GlintSpeed | Opt::GlintStrength => {
|
||||
s.file.set(
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v)),
|
||||
)
|
||||
}
|
||||
Opt::DistortionEffects
|
||||
| Opt::DamageTilt
|
||||
| Opt::PanoramaSpeed
|
||||
| Opt::GlintSpeed
|
||||
| Opt::GlintStrength => s.file.set(
|
||||
self.file_key().unwrap(),
|
||||
crate::options_file::V::Double(f64::from(v)),
|
||||
),
|
||||
Opt::Fov => s.fov = step(30.0, 110.0),
|
||||
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
|
||||
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
|
||||
@@ -669,7 +730,12 @@ impl Opt {
|
||||
| Opt::HighContrastBlockOutlines
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts => {
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::ModelPart(_)
|
||||
| Opt::AllowServerListing
|
||||
| Opt::RealmsNotifications
|
||||
| Opt::InGameNotification
|
||||
| Opt::DirectionalAudio => {
|
||||
let key = self.file_key().unwrap();
|
||||
s.file
|
||||
.set(key, crate::options_file::V::Bool(!s.file.bool(key)));
|
||||
@@ -718,6 +784,17 @@ impl Opt {
|
||||
s.file
|
||||
.set("musicFrequency", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::MainHand => s.main_hand_left = !s.main_hand_left,
|
||||
Opt::SharePresence => {
|
||||
let n = next(s.file.int("sharePresence").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
.set("sharePresence", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::MusicToast => {
|
||||
let n = next(s.file.int("musicToast").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
.set("musicToast", crate::options_file::V::Int(i32::from(n)));
|
||||
}
|
||||
Opt::ForceUnicode => s.force_unicode = !s.force_unicode,
|
||||
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
|
||||
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
|
||||
@@ -855,6 +932,10 @@ pub enum Page {
|
||||
Horizon,
|
||||
/// AI Agent: agent control on or off, whether an agent is connected, the connection details.
|
||||
Agent,
|
||||
/// SkinCustomizationScreen: the skin layers and Main Hand.
|
||||
Skin,
|
||||
/// OnlineOptionsScreen: server listing, Realms and friends notifications.
|
||||
Online,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Act {
|
||||
@@ -923,8 +1004,10 @@ pub enum Event {
|
||||
ButtonClick,
|
||||
/// Close the options screens (back to the pause menu or title).
|
||||
Close,
|
||||
/// Settings changed: apply them.
|
||||
/// Settings changed: apply and save them.
|
||||
Changed,
|
||||
/// A slider is being dragged: apply settings live but defer saving.
|
||||
SliderDrag,
|
||||
/// The language changed (reload translations).
|
||||
Language(String),
|
||||
/// The pack selection changed (reload resources).
|
||||
@@ -1173,6 +1256,8 @@ impl OptionsUi {
|
||||
Page::Main => "options.title",
|
||||
Page::Video => "options.videoTitle",
|
||||
Page::Sound => "options.sounds.title",
|
||||
Page::Skin => "options.skinCustomisation.title",
|
||||
Page::Online => "options.online.title",
|
||||
Page::Controls => "controls.title",
|
||||
Page::KeyBinds => "controls.keybinds.title",
|
||||
Page::Mouse => "options.mouse_settings.title",
|
||||
@@ -1432,13 +1517,29 @@ impl OptionsUi {
|
||||
self.filter_option_rows(rows)
|
||||
}
|
||||
Page::Sound => {
|
||||
// SoundOptionsScreen, without Audio Device: Horizon opens the
|
||||
// output device itself and cannot switch it.
|
||||
let mut rows = vec![Row::Big(Opt::Volume(0))];
|
||||
let others: Vec<Opt> = (1..SOUND_SOURCES.len()).map(Opt::Volume).collect();
|
||||
rows.extend(pairs(&others));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles]));
|
||||
// SoundOptionsScreen: Music Frequency starts the next row (with
|
||||
// Music Toast, not in Horizon yet).
|
||||
rows.extend(pairs(&[Opt::MusicFrequency]));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
|
||||
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
|
||||
rows
|
||||
}
|
||||
Page::Skin => {
|
||||
// SkinCustomizationScreen: the seven layers, then Main Hand.
|
||||
let mut opts: Vec<Opt> = (0..MODEL_PART_KEYS.len()).map(Opt::ModelPart).collect();
|
||||
opts.push(Opt::MainHand);
|
||||
pairs(&opts)
|
||||
}
|
||||
Page::Online => {
|
||||
// OnlineOptionsScreen without the Xbox and friends-list parts.
|
||||
let mut rows = vec![Row::Header("options.online.friends.header")];
|
||||
rows.extend(pairs(&[Opt::InGameNotification, Opt::SharePresence]));
|
||||
rows.push(Row::Header("options.online.servers.header"));
|
||||
rows.push(Row::Big(Opt::AllowServerListing));
|
||||
rows.push(Row::Header("options.online.realms.header"));
|
||||
rows.push(Row::Big(Opt::RealmsNotifications));
|
||||
rows
|
||||
}
|
||||
Page::Controls => {
|
||||
@@ -1633,15 +1734,15 @@ impl OptionsUi {
|
||||
list: false,
|
||||
});
|
||||
out.push(option_widget(s, Opt::Fov, cx - 154.0, 29.0, 150.0));
|
||||
out.push(inactive(button(
|
||||
out.push(button(
|
||||
cx + 4.0,
|
||||
29.0,
|
||||
150.0,
|
||||
tr("options.online"),
|
||||
Act::None,
|
||||
)));
|
||||
Act::Push(Page::Online),
|
||||
));
|
||||
let entries = [
|
||||
("options.skinCustomisation", None),
|
||||
("options.skinCustomisation", Some(Page::Skin)),
|
||||
("options.sounds", Some(Page::Sound)),
|
||||
("options.video", Some(Page::Video)),
|
||||
("options.controls", Some(Page::Controls)),
|
||||
@@ -2427,6 +2528,7 @@ impl OptionsUi {
|
||||
match action {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
@@ -2507,7 +2609,7 @@ impl OptionsUi {
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
events.push(Event::SliderDrag);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -2643,7 +2745,7 @@ impl OptionsUi {
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
events.push(Event::SliderDrag);
|
||||
}
|
||||
WKind::LanguageRow { code, .. } => {
|
||||
if self.language_choice.as_deref() == Some(code.as_str()) {
|
||||
@@ -2676,7 +2778,7 @@ impl OptionsUi {
|
||||
let slider = self.dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
if slider || graphics_slider {
|
||||
vec![Event::ButtonClick]
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -2692,7 +2794,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
self.graphics.scroll(level, size.1, amount);
|
||||
self.graphics.scroll(level, s, size.1, amount);
|
||||
return;
|
||||
}
|
||||
match &mut self.pack_screen {
|
||||
@@ -3666,22 +3768,22 @@ mod tests {
|
||||
let (x, y) = (slider.x + slider.w - 1.0, slider.y + 1.0);
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
|
||||
vec![Event::Changed]
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
assert_eq!(s.master_volume, 1.0);
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick]);
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
|
||||
assert!(ui.release().is_empty());
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false),
|
||||
vec![Event::Changed]
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
for cx in [-100.0, 250.0, 1000.0] {
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
|
||||
vec![Event::Changed]
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
}
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick]);
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
|
||||
assert!(
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], true, false)
|
||||
.is_empty()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! The option screens live in `options_ui`.
|
||||
use crate::keybinds::KeyBinds;
|
||||
use crate::options_file::{OptionsFile, V};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::sound_source::SOUND_SOURCES;
|
||||
@@ -448,6 +449,16 @@ impl Settings {
|
||||
file: f,
|
||||
}
|
||||
}
|
||||
/// `Options.createInformation`: what the server learns about this player.
|
||||
pub fn client_information(&self, view_distance: i8) -> ClientInformation {
|
||||
ClientInformation {
|
||||
chat_visibility: self.chat_visibility as i32,
|
||||
model_customisation: self.file.model_customisation(),
|
||||
main_hand: i32::from(!self.main_hand_left),
|
||||
allows_listing: self.file.bool("allowServerListing"),
|
||||
..ClientInformation::new(view_distance)
|
||||
}
|
||||
}
|
||||
/// The options file reflecting the typed fields; values the game did not
|
||||
/// change keep their stored form.
|
||||
pub fn to_file(&self) -> OptionsFile {
|
||||
@@ -706,6 +717,15 @@ pub enum Opt {
|
||||
PadGlyphs,
|
||||
PadMenuNav,
|
||||
PadKeyboard,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
MainHand,
|
||||
AllowServerListing,
|
||||
RealmsNotifications,
|
||||
InGameNotification,
|
||||
SharePresence,
|
||||
DirectionalAudio,
|
||||
MusicToast,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -491,15 +491,69 @@ pub fn texture_hash(url: &str) -> Option<String> {
|
||||
/// `SkinManager.TextureCache.registerTexture`: the texture id of a skin,
|
||||
/// `skins/` and the SHA-1 of its hash's UTF-16 code units.
|
||||
pub fn skin_location(hash: &str) -> String {
|
||||
location("skins/", hash)
|
||||
}
|
||||
/// `registerTexture` in the cape cache: `capes/` and the same SHA-1.
|
||||
pub fn cape_location(hash: &str) -> String {
|
||||
location("capes/", hash)
|
||||
}
|
||||
fn location(prefix: &str, hash: &str) -> String {
|
||||
let units: Vec<u8> = hash.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let digest = sha1(&units);
|
||||
let mut out = String::from("skins/");
|
||||
let mut out = String::from(prefix);
|
||||
for b in digest {
|
||||
out.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `PlayerSkin.cape`: the cape texture of textures whose lookup gave a skin
|
||||
/// (every download ready), if they name one.
|
||||
pub fn cape(textures: &Textures) -> Option<String> {
|
||||
textures
|
||||
.cape
|
||||
.as_deref()
|
||||
.and_then(texture_hash)
|
||||
.map(|hash| cape_location(&hash))
|
||||
}
|
||||
|
||||
/// A cape in a 64x64 skin slot: the 64x32 picture (or the older 22x17,
|
||||
/// which has the same layout) in its top half. Vanilla draws a cape of any
|
||||
/// size; a larger one is scaled down to 64x32 here.
|
||||
pub fn process_cape(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
|
||||
return None;
|
||||
}
|
||||
let mut img = vec![0u8; 64 * 64 * 4];
|
||||
let (fit_w, fit_h) = if w <= 64 && h <= 32 { (w, h) } else { (64, 32) };
|
||||
for y in 0..fit_h {
|
||||
for x in 0..fit_w {
|
||||
let (sx, sy) = (x * w / fit_w, y * h / fit_h);
|
||||
let (s, d) = ((sy * w + sx) * 4, (y * 64 + x) * 4);
|
||||
img[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
|
||||
}
|
||||
}
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// The cape the launcher gives an offline name (`HORIZON_OFFLINE_CAPE`, a
|
||||
/// PNG's path; launcher/src/skins.rs), read once as `process_cape` lays it out.
|
||||
pub fn offline_cape() -> Option<&'static [u8]> {
|
||||
static CAPE: std::sync::OnceLock<Option<Vec<u8>>> = std::sync::OnceLock::new();
|
||||
CAPE.get_or_init(|| {
|
||||
let path = std::env::var_os("HORIZON_OFFLINE_CAPE").filter(|p| !p.is_empty())?;
|
||||
let image = std::fs::read(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| crate::hud::decode_bytes(&bytes).map_err(|e| e.to_string()))
|
||||
.and_then(|(w, h, rgba)| process_cape(w, h, &rgba).ok_or("not an image".into()));
|
||||
image
|
||||
.inspect_err(|e| eprintln!("Horizon: no offline cape ({}): {e}", path.display()))
|
||||
.ok()
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
/// Where a texture download stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Download {
|
||||
@@ -650,9 +704,10 @@ impl Downloader {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
let state = match image {
|
||||
Some(rgba) => {
|
||||
if key.0 == Kind::Skin {
|
||||
let location = skin_location(&key.1);
|
||||
self.queue(location, rgba);
|
||||
match key.0 {
|
||||
Kind::Skin => self.queue(skin_location(&key.1), rgba),
|
||||
Kind::Cape => self.queue(cape_location(&key.1), rgba),
|
||||
Kind::Elytra => {}
|
||||
}
|
||||
Download::Ready
|
||||
}
|
||||
@@ -685,7 +740,8 @@ impl Downloader {
|
||||
.and_then(|b| crate::hud::decode_bytes(&b).ok())
|
||||
.and_then(|(w, h, rgba)| match kind {
|
||||
Kind::Skin => process_legacy_skin(w, h, &rgba),
|
||||
_ => Some(rgba),
|
||||
Kind::Cape => process_cape(w, h, &rgba),
|
||||
Kind::Elytra => Some(rgba),
|
||||
});
|
||||
let _ = send.send((key, image));
|
||||
});
|
||||
@@ -716,16 +772,21 @@ impl Downloader {
|
||||
if self.queued.contains(location) {
|
||||
return true;
|
||||
}
|
||||
let Some(digest) = location.strip_prefix("skins/") else {
|
||||
return false;
|
||||
};
|
||||
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
|
||||
if let Some(digest) = location.strip_prefix("skins/") {
|
||||
(digest, process_legacy_skin)
|
||||
} else if let Some(digest) = location.strip_prefix("capes/") {
|
||||
(digest, process_cape)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let image = self.images.get(location).cloned().or_else(|| {
|
||||
let file = self
|
||||
.root
|
||||
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
|
||||
.join(digest);
|
||||
let (w, h, rgba) = crate::hud::decode_bytes(&std::fs::read(&file).ok()?).ok()?;
|
||||
process_legacy_skin(w, h, &rgba)
|
||||
process(w, h, &rgba)
|
||||
});
|
||||
let Some(image) = image else {
|
||||
return false;
|
||||
@@ -961,6 +1022,31 @@ mod tests {
|
||||
assert_eq!(d.take_ready(), vec![("skins/def".to_owned(), vec![4])]);
|
||||
}
|
||||
|
||||
/// A cape fills the top half of a 64x64 slot; a larger one is scaled to 64x32.
|
||||
#[test]
|
||||
fn capes_fill_the_top_half_of_a_slot() {
|
||||
let at = |x: usize, y: usize| (y * 64 + x) * 4;
|
||||
let legacy = super::process_cape(22, 17, &[7; 22 * 17 * 4]).unwrap();
|
||||
assert_eq!(legacy.len(), 64 * 64 * 4);
|
||||
assert_eq!(legacy[at(21, 16)], 7);
|
||||
assert_eq!(legacy[at(22, 16)], 0);
|
||||
let mut hd = vec![0u8; 128 * 64 * 4];
|
||||
hd[(62 * 128 + 126) * 4] = 9;
|
||||
let hd = super::process_cape(128, 64, &hd).unwrap();
|
||||
assert_eq!(hd[at(63, 31)], 9);
|
||||
assert_eq!(hd[at(0, 32)], 0);
|
||||
assert!(super::process_cape(64, 32, &[0; 4]).is_none());
|
||||
let textures = super::Textures {
|
||||
cape: Some("http://textures.minecraft.net/texture/abc".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let cape = super::cape(&textures).unwrap();
|
||||
assert_eq!(
|
||||
cape["capes/".len()..],
|
||||
super::skin_location("abc")["skins/".len()..]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_components_supply_unsigned_head_skins() {
|
||||
let value = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYzEyMyJ9fX0=";
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
//! instance's random.
|
||||
use horizon_core::random::RandomSource;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// `SoundManager.EMPTY_SOUND_LOCATION`.
|
||||
pub const EMPTY: &str = "minecraft:empty";
|
||||
@@ -224,6 +228,34 @@ fn parse_sound(v: &Value) -> Option<Sound> {
|
||||
sentinel: Sentinel::None,
|
||||
})
|
||||
}
|
||||
/// The files under a namespace's `sounds/`, as `sounds/<path>` with `/`.
|
||||
fn sound_files(namespace: &Path) -> HashSet<String> {
|
||||
let mut files = HashSet::new();
|
||||
let mut stack = vec![namespace.join("sounds")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
// The listing's own type; a link is followed, as `is_file` does.
|
||||
let Ok(kind) = e.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let (dir, file) = if kind.is_symlink() {
|
||||
(path.is_dir(), path.is_file())
|
||||
} else {
|
||||
(kind.is_dir(), kind.is_file())
|
||||
};
|
||||
if dir {
|
||||
stack.push(path);
|
||||
} else if file && let Ok(rel) = path.strip_prefix(namespace) {
|
||||
files.insert(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
impl SoundRegistry {
|
||||
/// Every `assets/<namespace>/sounds.json` under a resource root, files
|
||||
/// validated against `assets/<namespace>/sounds/`.
|
||||
@@ -236,13 +268,19 @@ impl SoundRegistry {
|
||||
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
|
||||
.collect();
|
||||
namespaces.sort();
|
||||
// Each namespace's sound files are listed once, as the resource manager
|
||||
// lists a pack, instead of one file lookup per sound (thousands).
|
||||
let files: HashMap<&str, HashSet<String>> = namespaces
|
||||
.iter()
|
||||
.map(|ns| (ns.as_str(), sound_files(&assets.join(ns))))
|
||||
.collect();
|
||||
let exists = |ns: &str, path: &str| files.get(ns).is_some_and(|f| f.contains(path));
|
||||
let mut registry = Self::default();
|
||||
for namespace in namespaces {
|
||||
let Ok(text) = fs::read(assets.join(&namespace).join("sounds.json")) else {
|
||||
for namespace in &namespaces {
|
||||
let Ok(text) = fs::read(assets.join(namespace).join("sounds.json")) else {
|
||||
continue;
|
||||
};
|
||||
let exists = |ns: &str, path: &str| assets.join(ns).join(path).is_file();
|
||||
registry.add_file(&namespace, &text, &exists);
|
||||
registry.add_file(namespace, &text, &exists);
|
||||
}
|
||||
registry
|
||||
}
|
||||
@@ -369,6 +407,24 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
#[test]
|
||||
fn load_keeps_the_sound_files_a_root_holds() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sounds-{}", std::process::id()));
|
||||
let ns = root.join("assets/minecraft");
|
||||
fs::create_dir_all(ns.join("sounds/block/note")).unwrap();
|
||||
fs::write(ns.join("sounds/block/note/harp.ogg"), b"").unwrap();
|
||||
fs::write(
|
||||
ns.join("sounds.json"),
|
||||
br#"{"note": {"sounds": ["block/note/harp", "block/note/gone"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let r = SoundRegistry::load(&root);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let sounds = &r.get("minecraft:note").unwrap().sounds;
|
||||
let kept: Vec<&str> = sounds.iter().map(|s| s.location.as_str()).collect();
|
||||
assert_eq!(kept, ["minecraft:block/note/harp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_weigh_as_much_as_their_event() {
|
||||
let mut r = SoundRegistry::default();
|
||||
|
||||
@@ -24,6 +24,9 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
|
||||
// triangle's volume with the camera; then each rim vertex's normal against the apex.
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -197,39 +200,26 @@ fn sky_fog(color: vec3<f32>, dist: vec2<f32>) -> vec3<f32> {
|
||||
let f = max(linear_fog(dist.x, 0.0, end), linear_fog(dist.y, end, end));
|
||||
return mix(color, globals.fog_color.rgb, f * globals.fog_color.a);
|
||||
}
|
||||
fn rotate_x90(v: vec3<f32>) -> vec3<f32> { return vec3(v.x, -v.z, v.y); }
|
||||
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed).
|
||||
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed):
|
||||
// Moller-Trumbore from the origin along d, over normals built once a frame
|
||||
// (gfx/sunrise_fan.rs). The barycentrics are apex/s, k1/s and -k0/s; s is -det.
|
||||
fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
let alpha = globals.sunrise.a;
|
||||
if alpha <= 0.001 { return 0.0; }
|
||||
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
|
||||
let flip = select(0.0, 180.0, -globals.sun.z < 0.0);
|
||||
let theta = radians(flip + 90.0);
|
||||
let c = cos(theta);
|
||||
let s = sin(theta);
|
||||
if globals.sunrise.a <= 0.001 { return 0.0; }
|
||||
var best = 0.0;
|
||||
let apex = rotate_x90(vec3(-100.0 * s, 100.0 * c, 0.0));
|
||||
var k0 = dot(d, globals.sunrise_fan[16].xyz);
|
||||
for (var i = 0; i < 16; i++) {
|
||||
let a0 = f32(i) * 2.0 * PI / 16.0;
|
||||
let a1 = f32(i + 1) * 2.0 * PI / 16.0;
|
||||
let l0 = vec3(sin(a0) * 120.0, cos(a0) * 120.0, -cos(a0) * 40.0 * alpha);
|
||||
let l1 = vec3(sin(a1) * 120.0, cos(a1) * 120.0, -cos(a1) * 40.0 * alpha);
|
||||
let r0 = rotate_x90(vec3(l0.x * c - l0.y * s, l0.x * s + l0.y * c, l0.z));
|
||||
let r1 = rotate_x90(vec3(l1.x * c - l1.y * s, l1.x * s + l1.y * c, l1.z));
|
||||
// Moller-Trumbore from the origin along d.
|
||||
let e1 = r0 - apex;
|
||||
let e2 = r1 - apex;
|
||||
let pv = cross(d, e2);
|
||||
let det = dot(e1, pv);
|
||||
if abs(det) < 1e-6 { continue; }
|
||||
let tv = -apex;
|
||||
let u = dot(tv, pv) / det;
|
||||
let qv = cross(tv, e1);
|
||||
let v = dot(d, qv) / det;
|
||||
let t = dot(e2, qv) / det;
|
||||
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
|
||||
best = max(best, 1.0 - u - v);
|
||||
let triangle = globals.sunrise_fan[i];
|
||||
let k1 = dot(d, globals.sunrise_fan[16 + (i + 1) % 16].xyz);
|
||||
// A hit needs s on the side of w, so k0 and k1 on either side of 0.
|
||||
if k0 * triangle.w <= 0.0 && k1 * triangle.w >= 0.0 {
|
||||
let apex = dot(d, triangle.xyz);
|
||||
let s = apex + k1 - k0;
|
||||
if abs(s) >= 1e-6 && s * triangle.w > 0.0 && apex * s >= 0.0 && k1 * s >= 0.0
|
||||
&& k0 * s <= 0.0 {
|
||||
best = max(best, apex / s);
|
||||
}
|
||||
}
|
||||
k0 = k1;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
@@ -79,27 +79,31 @@ pub fn load_metadata(root: &Path) {
|
||||
}
|
||||
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
|
||||
}
|
||||
/// The sprite's nine-slice metadata; without any (no resources loaded), its
|
||||
/// GUI size and the widgets' usual borders.
|
||||
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
/// The sprite's nine-slice metadata, when its `.png.mcmeta` has any.
|
||||
fn nine_slice_metadata(sprite: &str) -> Option<NineSlice> {
|
||||
NINE_SLICE
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(sprite).copied())
|
||||
.unwrap_or(NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle"
|
||||
| "gui/sprites/widget/slider_handle_highlighted" => [2, 2, 2, 3],
|
||||
_ => [1; 4],
|
||||
},
|
||||
stretch_inner: false,
|
||||
})
|
||||
}
|
||||
/// The sprite's nine-slice metadata; without any (no resources loaded), its
|
||||
/// GUI size and the widgets' usual borders.
|
||||
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
nine_slice_metadata(sprite).unwrap_or(NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
"gui/sprites/widget/slider_handle" | "gui/sprites/widget/slider_handle_highlighted" => {
|
||||
[2, 2, 2, 3]
|
||||
}
|
||||
_ => [1; 4],
|
||||
},
|
||||
stretch_inner: false,
|
||||
})
|
||||
}
|
||||
/// The region `[x, y, w, h]` of a sprite `size` GUI pixels large, in its
|
||||
/// own units (`TextureAtlasSprite.getU(x / width)`).
|
||||
@@ -152,6 +156,35 @@ pub fn nine_slice(
|
||||
draw_nine_slice(batch, *r, scaling(sprite, r), [x, y, w, h], color);
|
||||
}
|
||||
}
|
||||
/// `GuiGraphicsExtractor.blitSprite`: a sprite over `w` × `h` as its
|
||||
/// `gui.scaling` says, nine-sliced, or stretched when it has no metadata.
|
||||
/// (`tile`, which no vanilla sprite uses, isn't read yet: it stretches.)
|
||||
pub fn blit_sprite(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
sprite: &str,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
color: u32,
|
||||
) {
|
||||
if let Some(r) = atlas.sprites.get(sprite) {
|
||||
draw_sprite(batch, *r, nine_slice_metadata(sprite), [x, y, w, h], color);
|
||||
}
|
||||
}
|
||||
fn draw_sprite(
|
||||
batch: &mut HudBatch,
|
||||
r: Rect,
|
||||
nine_slice: Option<NineSlice>,
|
||||
[x, y, w, h]: [f32; 4],
|
||||
color: u32,
|
||||
) {
|
||||
match nine_slice {
|
||||
Some(n) => draw_nine_slice(batch, r, n, [x, y, w, h], color),
|
||||
None => batch.rect_uv(x, y, w, h, r, color),
|
||||
}
|
||||
}
|
||||
fn draw_nine_slice(
|
||||
batch: &mut HudBatch,
|
||||
r: Rect,
|
||||
@@ -227,28 +260,33 @@ pub fn tiled(
|
||||
h: f32,
|
||||
size: f32,
|
||||
color: u32,
|
||||
) {
|
||||
tiled_sized(batch, atlas, sprite, x, y, w, h, [size, size], color);
|
||||
}
|
||||
/// Repeat a texture using its width and height in GUI pixels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tiled_sized(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
sprite: &str,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
size: [f32; 2],
|
||||
color: u32,
|
||||
) {
|
||||
let Some(r) = atlas.sprites.get(sprite).copied() else {
|
||||
return;
|
||||
};
|
||||
let mut yy = 0.0;
|
||||
while yy < h {
|
||||
let th = size.min(h - yy);
|
||||
let mut xx = 0.0;
|
||||
while xx < w {
|
||||
let tw = size.min(w - xx);
|
||||
batch.rect_uv(
|
||||
x + xx,
|
||||
y + yy,
|
||||
tw,
|
||||
th,
|
||||
sub(r, [size, size], [0.0, 0.0, tw, th]),
|
||||
color,
|
||||
);
|
||||
xx += size;
|
||||
}
|
||||
yy += size;
|
||||
}
|
||||
tile(
|
||||
batch,
|
||||
r,
|
||||
size,
|
||||
[0.0, 0.0, size[0], size[1]],
|
||||
[x, y, w, h],
|
||||
color,
|
||||
);
|
||||
}
|
||||
/// Text drawn at `scale` times its size (a scaled pose).
|
||||
pub fn text_scaled(
|
||||
@@ -434,7 +472,7 @@ pub fn separators(
|
||||
("gui/header_separator", "gui/footer_separator")
|
||||
};
|
||||
let w = batch.width;
|
||||
tiled(
|
||||
tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
header,
|
||||
@@ -442,10 +480,10 @@ pub fn separators(
|
||||
header_bottom - 2.0,
|
||||
w,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
tiled(
|
||||
tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
footer,
|
||||
@@ -453,7 +491,7 @@ pub fn separators(
|
||||
footer_top,
|
||||
w,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
@@ -1291,6 +1329,22 @@ mod tests {
|
||||
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
tile(
|
||||
&mut batch,
|
||||
sprite(32, 2),
|
||||
[32.0, 2.0],
|
||||
[0.0, 0.0, 32.0, 2.0],
|
||||
[0.0, 5.0, 40.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 2);
|
||||
assert_quad(q[0], ([0.0, 5.0, 32.0, 7.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
assert_quad(q[1], ([32.0, 5.0, 40.0, 7.0], [0.0, 0.0, 0.25, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_button_slices_in_its_gui_units() {
|
||||
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
|
||||
let n = faithful(
|
||||
@@ -1357,6 +1411,40 @@ mod tests {
|
||||
assert_quad(q[0], ([4.0, 4.0, 54.0, 54.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn blit_sprite_nine_slices_a_sprite_whose_metadata_says_so() {
|
||||
// Vanilla's tooltip/frame: 100x100, border 10, stretched centre.
|
||||
let n = faithful(
|
||||
r#"{"gui":{"scaling":{"type":"nine_slice","width":100,"height":100,"border":10,"stretch_inner":true}}}"#,
|
||||
);
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_sprite(
|
||||
&mut batch,
|
||||
sprite(100, 100),
|
||||
Some(n),
|
||||
[10.0, 10.0, 160.0, 26.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 9);
|
||||
// The corners keep their 10 pixels instead of stretching with the box.
|
||||
assert_quad(q[0], ([10.0, 10.0, 20.0, 20.0], [0.0, 0.0, 0.1, 0.1]));
|
||||
assert_quad(q[5], ([160.0, 26.0, 170.0, 36.0], [0.9, 0.9, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn blit_sprite_stretches_a_sprite_without_metadata() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw_sprite(
|
||||
&mut batch,
|
||||
sprite(9, 9),
|
||||
None,
|
||||
[0.0, 0.0, 27.0, 18.0],
|
||||
u32::MAX,
|
||||
);
|
||||
let q = quads(&batch);
|
||||
assert_eq!(q.len(), 1);
|
||||
assert_quad(q[0], ([0.0, 0.0, 27.0, 18.0], [0.0, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
#[test]
|
||||
fn borders_shrink_to_half_the_drawn_size() {
|
||||
let n = NineSlice {
|
||||
width: 20,
|
||||
|
||||
@@ -55,3 +55,79 @@ fn luma(c: vec3<f32>) -> f32 {
|
||||
}
|
||||
return vec4(b, centre.a);
|
||||
}
|
||||
|
||||
// TAA (`world_post.rs`): blend this frame into the history, reprojected from the
|
||||
// depth buffer through the camera's motion and clamped to this frame's 3x3
|
||||
// neighbourhood so that what moved or appeared does not ghost.
|
||||
struct Taa {
|
||||
reproject: mat4x4<f32>,
|
||||
// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
|
||||
jitter: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var history: texture_2d<f32>;
|
||||
@group(0) @binding(3) var depth: texture_depth_2d;
|
||||
@group(0) @binding(4) var<uniform> taa: Taa;
|
||||
|
||||
// Catmull-Rom over the history in 5 bilinear taps: sharper than one under motion.
|
||||
fn sample_history(uv: vec2<f32>) -> vec3<f32> {
|
||||
let size = vec2<f32>(textureDimensions(history));
|
||||
let pos = uv * size;
|
||||
let centre = floor(pos - 0.5) + 0.5;
|
||||
let f = pos - centre;
|
||||
let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
let w3 = f * f * (-0.5 + 0.5 * f);
|
||||
let w12 = w1 + w2;
|
||||
let t0 = (centre - 1.0) / size;
|
||||
let t3 = (centre + 2.0) / size;
|
||||
let t12 = (centre + w2 / w12) / size;
|
||||
let c = textureSampleLevel(history, world_sampler, vec2(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, t12, 0.0).rgb * (w12.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y)
|
||||
+ textureSampleLevel(history, world_sampler, vec2(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
|
||||
let weight = w12.x * w0.y + w0.x * w12.y + w12.x * w12.y + w3.x * w12.y + w12.x * w3.y;
|
||||
return max(c / weight, vec3(0.0));
|
||||
}
|
||||
|
||||
// It runs at the history's size, which upscaling makes larger than the world's.
|
||||
// The history is unjittered: each output pixel takes the world pixel whose
|
||||
// jittered sample lands nearest its centre, weighted by how near it lands.
|
||||
@fragment fn fs_taa(in: PostOut) -> @location(0) vec4<f32> {
|
||||
let size = vec2<f32>(textureDimensions(world));
|
||||
// The jitter moved the scene by `jitter` in clip space: where this pixel's
|
||||
// point was drawn, in world pixels.
|
||||
let drawn = (in.uv + taa.jitter.xy * vec2(0.5, -0.5)) * size;
|
||||
let last = vec2<i32>(size) - 1;
|
||||
let p = clamp(vec2<i32>(floor(drawn)), vec2(0), last);
|
||||
let centre = textureLoad(world, p, 0);
|
||||
var lo = centre.rgb;
|
||||
var hi = centre.rgb;
|
||||
// Reversed depth: the nearest of the 3x3 is the largest, and its motion keeps
|
||||
// the edges of what is in front from trailing.
|
||||
var near = 0.0;
|
||||
for (var y = -1; y <= 1; y++) {
|
||||
for (var x = -1; x <= 1; x++) {
|
||||
let q = clamp(p + vec2(x, y), vec2(0), last);
|
||||
let c = textureLoad(world, q, 0).rgb;
|
||||
lo = min(lo, c);
|
||||
hi = max(hi, c);
|
||||
near = max(near, textureLoad(depth, q, 0));
|
||||
}
|
||||
}
|
||||
let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
|
||||
let previous = taa.reproject * vec4(ndc, near, 1.0);
|
||||
let uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
|
||||
if taa.jitter.z > 0.0 || any(uv != clamp(uv, vec2(0.0), vec2(1.0))) {
|
||||
return vec4(centre.rgb, centre.a);
|
||||
}
|
||||
let past = clamp(sample_history(uv), lo, hi);
|
||||
// How far the sample landed from this pixel's centre, in output pixels: a
|
||||
// Gaussian of it scales the new frame's share, so that frames whose sample
|
||||
// fell far away (most of them when upscaling) barely move the pixel.
|
||||
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
|
||||
let share = 0.1 * exp(-2.0 * dot(off, off));
|
||||
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
|
||||
return vec4(mix(past, centre.rgb, share), centre.a);
|
||||
}
|
||||
@@ -302,7 +302,7 @@ fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
|
||||
for op in c["ops"].as_array().unwrap() {
|
||||
let name = op[0].as_str().unwrap();
|
||||
match name {
|
||||
"tick" => state.tick(),
|
||||
"tick" => state.tick([0.0; 3]),
|
||||
"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(),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Double chests for tools/test_chest_pair_parity.py: reads one JSON case per
|
||||
//! line (see VanillaChestPairOracle), a chest's block state at the origin and
|
||||
//! another block state at the offset "at", and prints what
|
||||
//! `horizon_client::containers::ChestHalf` pairs them into, as the game's
|
||||
//! menus and hoppers do: "double" with the right half's x,z then the left
|
||||
//! half's, or "single".
|
||||
//!
|
||||
//! usage: chest_pair_fixture INPUT OUTPUT
|
||||
use horizon_client::containers::{ChestHalf, is_chest_block};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
/// `minecraft:chest[facing=north,type=left]` as its name and properties.
|
||||
fn parse(state: &str) -> Option<(&str, BTreeMap<&str, &str>)> {
|
||||
let (name, props) = match state.split_once('[') {
|
||||
Some((name, rest)) => (name, rest.strip_suffix(']')?),
|
||||
None => (state, ""),
|
||||
};
|
||||
let props = props
|
||||
.split(',')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| p.split_once('='))
|
||||
.collect::<Option<_>>()?;
|
||||
Some((name, props))
|
||||
}
|
||||
|
||||
fn half<'a>(name: &'a str, props: &BTreeMap<&str, &'a str>) -> Option<ChestHalf<'a>> {
|
||||
Some(ChestHalf {
|
||||
block: name,
|
||||
kind: props.get("type")?,
|
||||
facing: props.get("facing")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let (name, props) = parse(case["state"].as_str()?)?;
|
||||
let (other_name, other_props) = parse(case["other"].as_str()?)?;
|
||||
let at = (
|
||||
i32::try_from(case["at"][0].as_i64()?).ok()?,
|
||||
i32::try_from(case["at"][1].as_i64()?).ok()?,
|
||||
);
|
||||
if !is_chest_block(name) {
|
||||
return None;
|
||||
}
|
||||
let own = half(name, &props)?;
|
||||
let paired = own.connected_step() == Some(at)
|
||||
&& half(other_name, &other_props).is_some_and(|other| own.pairs_with(&other));
|
||||
Some(if !paired {
|
||||
"single".to_owned()
|
||||
} else if own.kind == "right" {
|
||||
format!("double 0,0 {},{}", at.0, at.1)
|
||||
} else {
|
||||
format!("double {},{} 0,0", at.0, at.1)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: chest_pair_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Container menu titles for tools/test_container_title_parity.py: reads one
|
||||
//! JSON case per line (see VanillaContainerTitleOracle) and prints the title
|
||||
//! the server opens the container's menu with, as canonical NBT. A single
|
||||
//! block entity is placed as the game places it (`block_entity_item::place`,
|
||||
//! whose `CustomName` the game keeps in `World::block_names`), then named by
|
||||
//! `containers::display_name`; a double chest's halves by
|
||||
//! `containers::double_chest_name`.
|
||||
//!
|
||||
//! usage: container_title_fixture INPUT OUTPUT
|
||||
use horizon_client::block_entity_item::place;
|
||||
use horizon_client::block_entity_nbt::canonical;
|
||||
use horizon_client::containers::{default_name_key, display_name, double_chest_name};
|
||||
use horizon_client::save::text;
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
/// ParityData.canon of a text component: sorted keys.
|
||||
fn canon(tag: &Tag, out: &mut String) {
|
||||
match tag {
|
||||
Tag::Compound(map) => {
|
||||
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.push('{');
|
||||
for (i, (k, v)) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&k);
|
||||
out.push(':');
|
||||
canon(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Tag::List(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canon(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snbt(value: &Value) -> Option<Tag> {
|
||||
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
|
||||
}
|
||||
|
||||
/// The custom name the game keeps for a block entity saved as `value`.
|
||||
fn custom_name(value: &Value) -> Option<Option<String>> {
|
||||
let saved = canonical(&snbt(value)?)?;
|
||||
Some(text(saved.get("CustomName")))
|
||||
}
|
||||
|
||||
fn case(line: &str) -> Option<String> {
|
||||
let case: Value = serde_json::from_str(line).ok()?;
|
||||
let title = if let Some(first) = case.get("first") {
|
||||
let first = custom_name(first)?;
|
||||
let second = custom_name(&case["second"])?;
|
||||
double_chest_name(first.as_deref(), second.as_deref())
|
||||
} else {
|
||||
let saved = canonical(&snbt(&case["nbt"])?)?;
|
||||
// BlockItem.updateBlockEntityComponents, as Game::apply_placed_item runs it.
|
||||
let placed = match case.get("patch") {
|
||||
Some(p) => match snbt(p)? {
|
||||
Tag::Compound(patch) => place(&saved, &patch, true),
|
||||
_ => return None,
|
||||
},
|
||||
None => saved,
|
||||
};
|
||||
let key = default_name_key(case["block"].as_str()?)?;
|
||||
display_name(text(placed.get("CustomName")).as_deref(), key)
|
||||
};
|
||||
let mut out = String::new();
|
||||
canon(&title, &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: container_title_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -24,9 +24,11 @@ mod breaking_fixture;
|
||||
mod camera_probe;
|
||||
mod cauldron_fixture;
|
||||
mod chat_screen_fixture;
|
||||
mod chest_pair_fixture;
|
||||
mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod container_title_fixture;
|
||||
mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
@@ -90,6 +92,7 @@ mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
mod text_display_fixture;
|
||||
mod text_display_render_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
@@ -115,9 +118,11 @@ probes! {
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
chest_pair_fixture,
|
||||
cloud_fixture,
|
||||
command_parse_fixture,
|
||||
container_screen_probe,
|
||||
container_title_fixture,
|
||||
creative_search_fixture,
|
||||
emitter_particles_probe,
|
||||
enchanting_fixture,
|
||||
@@ -180,6 +185,7 @@ probes! {
|
||||
toggle_keys_probe,
|
||||
tooltip_display_fixture,
|
||||
text_display_fixture,
|
||||
text_display_render_fixture,
|
||||
trading_fixture,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
|
||||
@@ -67,8 +67,9 @@ struct Scenario {
|
||||
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
|
||||
hurts: Vec<(i32, usize)>,
|
||||
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
|
||||
/// (tick, mob index); the scenario only feeds each animal its food.
|
||||
feeds: Vec<(i32, usize)>,
|
||||
/// (tick, mob index, None); the scenario only feeds each animal its food.
|
||||
/// `interact` uses another item on a horse: (tick, mob index, Some(item)).
|
||||
feeds: Vec<(i32, usize, Option<String>)>,
|
||||
hand: Option<String>,
|
||||
mobs: Vec<MobSpec>,
|
||||
}
|
||||
@@ -108,7 +109,14 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
}
|
||||
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
|
||||
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
|
||||
"feed" => s
|
||||
.feeds
|
||||
.push((f[1].parse().unwrap(), f[2].parse().unwrap(), None)),
|
||||
"interact" => s.feeds.push((
|
||||
f[1].parse().unwrap(),
|
||||
f[2].parse().unwrap(),
|
||||
Some(f[3].to_owned()),
|
||||
)),
|
||||
"player" => {
|
||||
s.player = Some([
|
||||
f[1].parse().unwrap(),
|
||||
@@ -1209,10 +1217,22 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
events.append(&mut mobs[i].outbox);
|
||||
}
|
||||
for &(tick, i) in &s.feeds {
|
||||
for (tick, i, item) in &s.feeds {
|
||||
let (tick, i) = (*tick, *i);
|
||||
if tick != t || i >= mobs.len() || mobs[i].removed {
|
||||
continue;
|
||||
}
|
||||
// Horse.mobInteract's held-item branch (makeMad).
|
||||
if let Some(item) = item {
|
||||
horizon_client::mobs::equine::held_item(
|
||||
&mut mobs[i],
|
||||
item,
|
||||
false,
|
||||
item == "minecraft:golden_dandelion",
|
||||
&mut events,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((name, volume, pitch))) =
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! A text display's text for differential tests against vanilla's
|
||||
//! `TextDisplayRenderer.submitInner` (tools/test_text_display_render_parity.py):
|
||||
//! decodes each case's entity data from vanilla's wire bytes, lays the text out
|
||||
//! with `horizon_client::text_display` and prints the pose and every quad in
|
||||
//! text units [x, y, z, ARGB], as VanillaTextDisplayRenderOracle does.
|
||||
//!
|
||||
//! usage: text_display_render_fixture RESOURCES INPUTS OUTPUT
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
hud_layout::background_color,
|
||||
text_display::{self, Lines, TextDisplayData},
|
||||
};
|
||||
use horizon_protocol::{ids, packets::play::Clientbound};
|
||||
use serde_json::{Value, json};
|
||||
use std::{error::Error, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let [_, resources, inputs, output] = args.as_slice() else {
|
||||
return Err("usage: text_display_render_fixture RESOURCES INPUTS OUTPUT".into());
|
||||
};
|
||||
let atlas = HudAtlas::build(std::path::Path::new(resources))?;
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
|
||||
for line in std::fs::read_to_string(inputs)?.lines() {
|
||||
let input: Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap_or_default();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let Clientbound::SetEntityData(packet) =
|
||||
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
|
||||
else {
|
||||
return Err("not entity data".into());
|
||||
};
|
||||
let mut d = TextDisplayData::default();
|
||||
d.apply(&packet.values);
|
||||
let lines = Lines::split(&atlas.font, &d.text, d.line_width);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let default_background = background_color(
|
||||
input["chat_only"].as_bool().unwrap_or(false),
|
||||
input["background_opacity"].as_f64().unwrap_or(0.0),
|
||||
0.25,
|
||||
);
|
||||
text_display::draw(&mut batch, &atlas, &d, &lines, default_background);
|
||||
let quads: Vec<Value> = batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let corners: Vec<Value> = [0, 1, 2, 5]
|
||||
.map(|i| {
|
||||
let v = q[i];
|
||||
let [r, g, b, a] = v.color.to_le_bytes().map(u32::from);
|
||||
let argb = (a << 24 | r << 16 | g << 8 | b) as i32;
|
||||
json!([v.pos[0], v.pos[1], v.depth, argb])
|
||||
})
|
||||
.into();
|
||||
corners.into()
|
||||
})
|
||||
.collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
json!({"pose": lines.pose().to_cols_array(), "quads": quads})
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1692,6 +1692,33 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `FireworkRocketEntity.readAdditionalSaveData`: Life, LifeTime (0 when
|
||||
/// absent: a bare `/summon` explodes at once), FireworksItem and
|
||||
/// ShotAtAngle, with `Projectile`'s Owner and LeftOwner.
|
||||
pub struct RocketNbt {
|
||||
pub position: Vec3,
|
||||
pub motion: Vec3,
|
||||
pub life: i32,
|
||||
pub lifetime: i32,
|
||||
pub item: Option<crate::items::ItemStack>,
|
||||
pub angled: bool,
|
||||
pub owner: Option<Who>,
|
||||
pub left_owner: bool,
|
||||
}
|
||||
pub fn firework_rocket_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<RocketNbt> {
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
Some(RocketNbt {
|
||||
position: position(tag)?,
|
||||
motion: motion(tag),
|
||||
life: number(tag.get("Life")).unwrap_or(0) as i32,
|
||||
lifetime: number(tag.get("LifeTime")).unwrap_or(0) as i32,
|
||||
item: tag.get("FireworksItem").and_then(item_from_nbt),
|
||||
angled: flag(tag, "ShotAtAngle"),
|
||||
owner: (player.is_some() && owner == player).then_some(Who::Player),
|
||||
left_owner: flag(tag, "LeftOwner"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn falling_block_to_nbt(
|
||||
|
||||
@@ -4215,8 +4215,15 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Minecraft.startUseItem`: the main hand, then the off hand.
|
||||
fn use_item_as_player(&mut self) -> Result<Option<Hand>> {
|
||||
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass).
|
||||
// Spectators only look (MultiPlayerGameMode.useItemOn/useItem pass),
|
||||
// but on a server a spectator's use on a block opens the block's
|
||||
// menu (ServerPlayerGameMode.useItemOn).
|
||||
if self.spectator() {
|
||||
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
self.spectator_open_menu(hit.pos);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
// A carrot or warped fungus on a stick boosts the ridden pig or strider.
|
||||
@@ -4767,7 +4774,9 @@ impl Game<'_> {
|
||||
self.tick_crossbow(slot, ticks, &stack);
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:trident" {
|
||||
// TridentItem and EnderEyeItem: used until released (an eye's
|
||||
// getUseDuration 0 never counts down to finishUsingItem).
|
||||
if matches!(&*stack.item, "minecraft:trident" | "minecraft:ender_eye") {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
@@ -6633,9 +6642,15 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
part(batch, tex, 0.0, 0.0, 176.0, 166.0, ox, oy);
|
||||
let title = self.container_title(self.translate(key, fallback));
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
// A server's furnace menu has the title it was given.
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
horizon_client::text::Text::literal(
|
||||
self.container_title(self.translate(key, fallback)),
|
||||
)
|
||||
.resolve_legacy()
|
||||
});
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
ox + (176.0 - tw) / 2.0,
|
||||
@@ -7329,6 +7344,13 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
} else if self.creative_inventory_open() {
|
||||
// CreativeModeInventoryScreen.slotClicked's THROW on the Survival Inventory tab: the
|
||||
// stack goes to the server (handleCreativeModeItemDrop), the slot follows with the
|
||||
// others (SetCreativeModeSlot).
|
||||
for stack in dropped {
|
||||
self.remote_creative_throw(stack);
|
||||
}
|
||||
}
|
||||
if kind == Kind::Loom {
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
@@ -7528,7 +7550,8 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(moved) = self.last_quick_moved.clone() {
|
||||
if self.remote.is_some() {
|
||||
// The creative screen clicks its own menu, on a server too.
|
||||
if self.remote.is_some() && self.screen != Screen::Creative {
|
||||
self.remote_quick_move_all(slot, &moved);
|
||||
} else {
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
@@ -11866,8 +11889,9 @@ impl Game<'_> {
|
||||
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
|
||||
// player who may build. Adventure players only get one with a CAN_BREAK or
|
||||
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
|
||||
// components nor opens menus for spectators, so those never show it.
|
||||
let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
|
||||
// components nor opens menus for spectators, so those never show it. An open
|
||||
// screen does not hide it: vanilla checks the HUD and the game mode, not the screen.
|
||||
let outline = if !self.ui.hide_gui && self.may_build() {
|
||||
self.pick()
|
||||
.map(|h| {
|
||||
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
|
||||
|
||||
@@ -658,15 +658,30 @@ 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, chest)) = any_boat_wood(&stack.item) else {
|
||||
let Some(boat) = self.boat_to_place(stack) else {
|
||||
return false;
|
||||
};
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return false;
|
||||
self.next_entity += 1;
|
||||
let key = (
|
||||
boat.position.x.floor() as i32 >> 4,
|
||||
boat.position.z.floor() as i32 >> 4,
|
||||
);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
let Some((eye, dir)) = self.aim() else {
|
||||
return false;
|
||||
};
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The boat `BoatItem.use` would place for `stack` (its SUCCESS), or
|
||||
/// None when it aims at nothing (PASS) or the spot is taken (FAIL).
|
||||
pub(super) fn boat_to_place(&self, stack: &ItemStack) -> Option<Boat> {
|
||||
let (wood, chest) = any_boat_wood(&stack.item)?;
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return None;
|
||||
}
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach = self.reach();
|
||||
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
|
||||
// block along the ray.
|
||||
@@ -706,7 +721,7 @@ impl Game<'_> {
|
||||
}
|
||||
(Some(b), None) => b.0,
|
||||
(None, Some(w)) => w.0,
|
||||
(None, None) => return false,
|
||||
(None, None) => return None,
|
||||
};
|
||||
let mut boat = Boat::new(
|
||||
self.next_entity + 1,
|
||||
@@ -730,17 +745,9 @@ impl Game<'_> {
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
let key = (hit[0].floor() as i32 >> 4, hit[2].floor() as i32 >> 4);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
Some(boat)
|
||||
}
|
||||
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
|
||||
@@ -95,6 +95,11 @@ impl Game<'_> {
|
||||
/// `BookViewScreen.mouseClicked`: the click event of the text under the
|
||||
/// cursor on the page.
|
||||
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
|
||||
self.book_style_at(page, left)?.click
|
||||
}
|
||||
/// `ActiveTextCollector` over the page: the style of the character
|
||||
/// under the cursor, for its click and hover events.
|
||||
fn book_style_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::Style> {
|
||||
let (mx, my) = self.cursor;
|
||||
let row = ((my - 32.0) / 9.0).floor();
|
||||
if row < 0.0 || row >= LINES as f32 {
|
||||
@@ -109,7 +114,7 @@ impl Game<'_> {
|
||||
for (c, style) in line.chars() {
|
||||
x -= self.gui.atlas.font.advance(c, style);
|
||||
if x < 0.0 {
|
||||
return style.click.clone();
|
||||
return Some(style.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -139,22 +144,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
ClickEvent::RunCommand(command) => self.click_command(&command),
|
||||
ClickEvent::SuggestCommand(command) => {
|
||||
// defaultHandleClickEvent: the chat screen takes it over.
|
||||
if self.player.is_some() {
|
||||
self.close_book();
|
||||
self.open_chat(&command);
|
||||
}
|
||||
// closeContainerOnServer, then clickCommandAction with no screen
|
||||
// after the command: the book closes and the command runs.
|
||||
ClickEvent::RunCommand(command) => {
|
||||
self.close_book();
|
||||
self.click_command(&command);
|
||||
}
|
||||
// Screen.defaultHandleClickEvent: copied, the book stays open.
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
// BookViewScreen.handleClickEvent: the rest is
|
||||
// Screen.defaultHandleGameClickEvent's.
|
||||
// defaultHandleGameClickEvent: the dialog opens over the book.
|
||||
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
|
||||
_ => {}
|
||||
// SuggestCommand's insertText does nothing on a book; a server's
|
||||
// text never opens a file.
|
||||
ClickEvent::SuggestCommand(_) | ClickEvent::OpenFile(_) => {}
|
||||
}
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
@@ -1314,6 +1319,16 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
|
||||
// show_text hover event, as the chat shows it.
|
||||
if let Some(hover) = pages
|
||||
.get(*page)
|
||||
.and_then(|t| self.book_style_at(t, left))
|
||||
.and_then(|s| s.hover)
|
||||
{
|
||||
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
|
||||
self.draw_tooltip_lines(batch, &lines, mx, my);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ use horizon_client::{
|
||||
screen_draw::Measure,
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// The tree the chat parses and completes with: a server's, once it has
|
||||
/// sent one (ClientPacketListener.commands), else the game's own.
|
||||
@@ -123,6 +127,64 @@ impl UiState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids `build_registries` lists from the prepared resources' folders.
|
||||
/// The folders don't change while the game runs, so each root is read once
|
||||
/// instead of at every world opening (thousands of files).
|
||||
struct ResourceIds {
|
||||
recipes: Vec<String>,
|
||||
damage_types: Vec<String>,
|
||||
loot_tables: Vec<String>,
|
||||
templates: Vec<String>,
|
||||
}
|
||||
static RESOURCE_IDS: Mutex<Vec<(PathBuf, Arc<ResourceIds>)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
let mut cache = RESOURCE_IDS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, ids)) = cache.iter().find(|(cached, _)| cached == root) {
|
||||
return ids.clone();
|
||||
}
|
||||
let data = root.join("data/minecraft");
|
||||
let ids = Arc::new(ResourceIds {
|
||||
recipes: ids_under(&data.join("recipe"), "json"),
|
||||
damage_types: ids_under(&data.join("damage_type"), "json"),
|
||||
loot_tables: ids_under(&data.join("loot_table"), "json"),
|
||||
templates: ids_under(&root.join("worldgen/structure/minecraft"), "nbt"),
|
||||
});
|
||||
cache.push((root.to_owned(), ids.clone()));
|
||||
ids
|
||||
}
|
||||
|
||||
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_owned()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
// The listing's own type, without another look at each file; a
|
||||
// link is followed, as `Path::is_dir` does.
|
||||
let is_dir = e
|
||||
.file_type()
|
||||
.is_ok_and(|t| t.is_dir() || (t.is_symlink() && path.is_dir()));
|
||||
if is_dir {
|
||||
stack.push(path);
|
||||
} else if path.extension().and_then(|x| x.to_str()) == Some(extension)
|
||||
&& let Ok(rel) = path.strip_prefix(root)
|
||||
{
|
||||
out.push(format!(
|
||||
"minecraft:{}",
|
||||
rel.with_extension("").to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
|
||||
pub(super) fn allow_commands(&self) -> bool {
|
||||
@@ -146,61 +208,11 @@ impl Game<'_> {
|
||||
let permission = owner_permission(self.allow_commands());
|
||||
let reg = &mut self.ui.registries;
|
||||
reg.permission = permission;
|
||||
{
|
||||
let dir = |sub: &str| -> Vec<String> {
|
||||
let root = self.options.resources.join("data/minecraft").join(sub);
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.clone()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&d) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().and_then(|x| x.to_str()) == Some("json")
|
||||
&& let Ok(rel) = p.strip_prefix(&root)
|
||||
{
|
||||
out.push(format!(
|
||||
"minecraft:{}",
|
||||
rel.with_extension("").to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
self.ui.registries.recipes = dir("recipe");
|
||||
self.ui.registries.damage_types = dir("damage_type");
|
||||
self.ui.registries.loot_tables = dir("loot_table");
|
||||
self.ui.registries.templates = {
|
||||
let root = self.options.resources.join("worldgen/structure/minecraft");
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.clone()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&d) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().and_then(|x| x.to_str()) == Some("nbt")
|
||||
&& let Ok(rel) = p.strip_prefix(&root)
|
||||
{
|
||||
out.push(format!(
|
||||
"minecraft:{}",
|
||||
rel.with_extension("").to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
}
|
||||
let ids = resource_ids(&self.options.resources);
|
||||
self.ui.registries.recipes = ids.recipes.clone();
|
||||
self.ui.registries.damage_types = ids.damage_types.clone();
|
||||
self.ui.registries.loot_tables = ids.loot_tables.clone();
|
||||
self.ui.registries.templates = ids.templates.clone();
|
||||
self.ui.registries.sounds = self.audio.event_names();
|
||||
self.ui.registries.attributes = [
|
||||
"armor",
|
||||
@@ -961,7 +973,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
|
||||
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
|
||||
pub(super) fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
|
||||
let split = self
|
||||
.gui
|
||||
.atlas
|
||||
@@ -1224,3 +1236,24 @@ fn owner_permission(allow_commands: bool) -> u8 {
|
||||
/// `ConfirmLinkScreen`'s buttons, left to right: yes, copy, no.
|
||||
const LINK_YES: usize = 0;
|
||||
const LINK_COPY: usize = 1;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_ids_are_listed_once_per_root() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
|
||||
let recipe = root.join("data/minecraft/recipe");
|
||||
std::fs::create_dir_all(recipe.join("sub")).unwrap();
|
||||
std::fs::write(recipe.join("b.json"), "{}").unwrap();
|
||||
std::fs::write(recipe.join("sub/a.json"), "{}").unwrap();
|
||||
std::fs::write(recipe.join("notes.txt"), "").unwrap();
|
||||
let first = resource_ids(&root);
|
||||
assert_eq!(first.recipes, ["minecraft:b", "minecraft:sub/a"]);
|
||||
assert!(first.loot_tables.is_empty());
|
||||
std::fs::write(recipe.join("c.json"), "{}").unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &resource_ids(&root)));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,10 @@ pub(super) struct CombatTracker {
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
/// The exploding mob named itself (`MobEvent::ExplosionSource`), so the
|
||||
/// blast leaves it out (`getEntities(source, box)`). A source guessed
|
||||
/// from the mobs near the centre is still hurt: it may be a bystander.
|
||||
pub(super) explosion_source_named: bool,
|
||||
/// The blast now going off comes from a primed TNT, creeper, wither
|
||||
/// skull, wither or TNT minecart, the sources that empty a hive they
|
||||
/// destroy (`BeehiveBlock.getDrops`).
|
||||
|
||||
@@ -1929,7 +1929,10 @@ impl Game<'_> {
|
||||
fn spawned_mark(&self) -> (u32, usize) {
|
||||
(
|
||||
self.next_entity,
|
||||
self.tnts.len() + self.falling.len() + self.creatures.projectiles.len(),
|
||||
self.tnts.len()
|
||||
+ self.falling.len()
|
||||
+ self.creatures.projectiles.len()
|
||||
+ self.rockets.len(),
|
||||
)
|
||||
}
|
||||
/// BlockStateArgument -> registry state.
|
||||
|
||||
@@ -25,7 +25,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
|
||||
let _ = hotbar::save(&path, &rows);
|
||||
if let Err(e) = hotbar::save(&path, &rows) {
|
||||
// saveHotbar sets the message inside its try, so a failed save says nothing.
|
||||
log::warn!("the creative hotbar was not saved: {e}");
|
||||
return;
|
||||
}
|
||||
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
|
||||
let message = Text::translate(
|
||||
"inventory.hotbarSaved",
|
||||
|
||||
@@ -1301,6 +1301,7 @@ impl Game<'_> {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
self.combat.explosion_source_named = noted;
|
||||
// Only a creeper or a wither empties the hives it blows up.
|
||||
// The creeper is already gone from the list, so the event
|
||||
// carries its kind.
|
||||
@@ -1310,6 +1311,7 @@ impl Game<'_> {
|
||||
self.explode(at, radius)
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
self.combat.explosion_source_named = false;
|
||||
result?
|
||||
}
|
||||
MobEvent::AttackMob {
|
||||
@@ -4552,15 +4554,10 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
if &*mob.kind.id == "minecraft:player" {
|
||||
// PlayerRenderer's WingsLayer and SpinAttackEffectLayer.
|
||||
self.player_elytra_geometry(
|
||||
mob,
|
||||
mob.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
// PlayerRenderer's CapeLayer, WingsLayer and SpinAttackEffectLayer.
|
||||
let chest = mob.extra.chest.as_deref();
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
|
||||
@@ -203,7 +203,7 @@ impl Game<'_> {
|
||||
power,
|
||||
1.0,
|
||||
);
|
||||
self.launch_angled_rocket(at, shot.velocity, ammo);
|
||||
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
|
||||
}
|
||||
if !rocket {
|
||||
let kind = ProjectileKind::Arrow {
|
||||
|
||||
@@ -416,6 +416,13 @@ impl Game<'_> {
|
||||
],
|
||||
);
|
||||
}
|
||||
// Horizon's own: HDR's output while the setting is on, under the GPU (a group of its
|
||||
// own would move others to the other column).
|
||||
if self.settings.horizon.hdr
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
d.group("system", [r.hdr_line()]);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
@@ -917,6 +924,17 @@ impl Game<'_> {
|
||||
(t.pitch, t.yaw),
|
||||
);
|
||||
}
|
||||
// EntityType.EYE_OF_ENDER: 0.25 by 0.25, never turned.
|
||||
for (previous, now) in self.end.eye_motions() {
|
||||
let v = |p: glam::DVec3| Vec3::new(p.x, p.y, p.z);
|
||||
g.hitbox(
|
||||
at(v(previous), v(now)),
|
||||
(0.25, 0.25),
|
||||
0.2125,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for r in &self.rockets {
|
||||
// Projectile.updateRotation: the look follows the motion.
|
||||
let v = r.velocity;
|
||||
|
||||
@@ -987,7 +987,7 @@ impl Game<'_> {
|
||||
"minecraft:firework_rocket" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.launch_angled_rocket(at, shot.velocity, &one);
|
||||
self.launch_angled_rocket(at, shot.velocity, &one, None);
|
||||
}
|
||||
_ => {
|
||||
let kind = match item.as_str() {
|
||||
|
||||
@@ -181,6 +181,10 @@ impl EndState {
|
||||
survive: false,
|
||||
});
|
||||
}
|
||||
/// Where each eye in flight was last tick and is now (the hitbox view).
|
||||
pub(super) fn eye_motions(&self) -> impl Iterator<Item = (glam::DVec3, glam::DVec3)> + '_ {
|
||||
self.eyes.iter().map(|e| (e.previous, e.position))
|
||||
}
|
||||
/// `EyeOfEnder.tick` on a server's client: it moves by its last known
|
||||
/// motion; only the server steers it.
|
||||
pub(super) fn step_remote_eyes(&mut self) {
|
||||
@@ -329,6 +333,13 @@ impl Game<'_> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// EnderEyeItem.use's `getPlayerPOVHitResult`: whether the block aimed
|
||||
/// at is an End portal frame (the use passes there).
|
||||
pub(super) fn aims_at_end_portal_frame(&self) -> bool {
|
||||
self.pick()
|
||||
.is_some_and(|hit| &*self.block_name(hit.pos) == "minecraft:end_portal_frame")
|
||||
}
|
||||
|
||||
/// EnderEyeItem.useOn: fill an empty End portal frame, then light the
|
||||
/// portal when the ring of twelve filled frames is complete.
|
||||
pub(super) fn use_ender_eye(&mut self, stack: &ItemStack, use_on_block: bool) -> Result<bool> {
|
||||
@@ -345,7 +356,12 @@ impl Game<'_> {
|
||||
if matches!(self.guest_use, Some(super::GuestUse::OnBlock(_))) {
|
||||
return Ok(false);
|
||||
}
|
||||
// EnderEyeItem.use: throw the eye towards the nearest stronghold.
|
||||
// EnderEyeItem.use: startUsingItem (getUseDuration 0, so the use
|
||||
// lasts until the button lets go), then the eye is thrown towards
|
||||
// the nearest stronghold if there is one (CONSUME without).
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.throw_ender_eye();
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
@@ -382,9 +382,14 @@ impl Game<'_> {
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
// Mobs.
|
||||
// Mobs, but not the one exploding (`getEntities(source, box)`): a
|
||||
// wither's spawn blast neither hurts nor pushes it.
|
||||
let spared = exploding.filter(|_| self.combat.explosion_source_named);
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
for m in mobs.iter_mut().filter(|m| m.alive()) {
|
||||
for m in mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.alive() && spared != Some(m.id))
|
||||
{
|
||||
let hurtable = teams_allow(self, self.mob_team(m.id));
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
|
||||
@@ -7,10 +7,12 @@ use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light,
|
||||
collisions::SceneCollisions,
|
||||
entity_nbt::RocketNbt,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind},
|
||||
mobs::{Damage, DamageKind, Species, Who},
|
||||
particles::{FireworkExplosion, ParticleOptions, ParticleType},
|
||||
projectiles::ProjectileWorld,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -30,9 +32,14 @@ pub(super) struct Rocket {
|
||||
pub(super) explosions: Vec<FireworkExplosion>,
|
||||
/// Boosting the player (attachedToEntity).
|
||||
pub(super) attached: bool,
|
||||
/// On a server, the player it boosts when not this game's own: a guest's
|
||||
/// connection.
|
||||
/// On a server, the guest (by connection) who launched it, or whom it
|
||||
/// boosts: `owner`'s `Who::Player` then means them, not the host.
|
||||
pub(super) guest: Option<u32>,
|
||||
/// `Projectile.getOwner`: the player who launched it, credited with
|
||||
/// what its blast hurts.
|
||||
pub(super) owner: Option<Who>,
|
||||
/// `Projectile.leftOwner`: until then it can't hit its owner.
|
||||
left_owner: bool,
|
||||
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
|
||||
/// boosting happens when it is the local player.
|
||||
pub(super) attached_to: Option<u32>,
|
||||
@@ -57,11 +64,36 @@ impl Rocket {
|
||||
explosions,
|
||||
attached: false,
|
||||
guest: None,
|
||||
owner: None,
|
||||
left_owner: false,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item,
|
||||
}
|
||||
}
|
||||
/// A rocket loaded from its NBT (`/summon`, a saved chunk).
|
||||
pub(super) fn from_nbt(r: RocketNbt) -> Self {
|
||||
let item = r
|
||||
.item
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:firework_rocket", 1));
|
||||
let (_, explosions) = fireworks_of(&item);
|
||||
Self {
|
||||
id: 0,
|
||||
position: r.position,
|
||||
previous: r.position,
|
||||
velocity: r.motion,
|
||||
life: r.life,
|
||||
lifetime: r.lifetime,
|
||||
explosions,
|
||||
attached: false,
|
||||
guest: None,
|
||||
owner: r.owner,
|
||||
left_owner: r.left_owner,
|
||||
attached_to: None,
|
||||
angled: r.angled,
|
||||
item,
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: the rocket flies on by itself between
|
||||
/// the server's updates (a rocket that follows an entity is placed by the
|
||||
/// caller).
|
||||
@@ -72,6 +104,19 @@ impl Rocket {
|
||||
}
|
||||
self.life += 1;
|
||||
}
|
||||
/// The move of a rocket attached to no entity: sped up and rising, or an
|
||||
/// angled one's own motion.
|
||||
fn next_motion(&self) -> Vec3 {
|
||||
if self.angled {
|
||||
self.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
self.velocity.x * 1.15,
|
||||
self.velocity.y + 0.04,
|
||||
self.velocity.z * 1.15,
|
||||
)
|
||||
}
|
||||
}
|
||||
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
|
||||
/// up (an angled one keeps its motion) and rise, then move. True when a
|
||||
/// block stopped it.
|
||||
@@ -85,15 +130,7 @@ impl Rocket {
|
||||
self.position.z + h,
|
||||
),
|
||||
);
|
||||
let wanted = if self.angled {
|
||||
self.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
self.velocity.x * 1.15,
|
||||
self.velocity.y + 0.04,
|
||||
self.velocity.z * 1.15,
|
||||
)
|
||||
};
|
||||
let wanted = self.next_motion();
|
||||
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
|
||||
return false;
|
||||
};
|
||||
@@ -157,6 +194,8 @@ impl Game<'_> {
|
||||
explosions,
|
||||
attached,
|
||||
guest: None,
|
||||
owner: None,
|
||||
left_owner: false,
|
||||
attached_to: None,
|
||||
angled: false,
|
||||
item: stack.clone(),
|
||||
@@ -172,7 +211,9 @@ impl Game<'_> {
|
||||
hit.point[1] + f64::from(hit.face[1]) * 0.15,
|
||||
hit.point[2] + f64::from(hit.face[2]) * 0.15,
|
||||
);
|
||||
let rocket = self.new_rocket(at, stack, false);
|
||||
let mut rocket = self.new_rocket(at, stack, false);
|
||||
rocket.owner = Some(Who::Player);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
@@ -182,10 +223,18 @@ impl Game<'_> {
|
||||
}
|
||||
/// A rocket shot at an angle (`DATA_SHOT_AT_ANGLE`): out of a dispenser
|
||||
/// (`FireworkRocketItem.asProjectile`) or a crossbow.
|
||||
pub(super) fn launch_angled_rocket(&mut self, at: Vec3, velocity: Vec3, stack: &ItemStack) {
|
||||
pub(super) fn launch_angled_rocket(
|
||||
&mut self,
|
||||
at: Vec3,
|
||||
velocity: Vec3,
|
||||
stack: &ItemStack,
|
||||
owner: Option<Who>,
|
||||
) {
|
||||
let mut rocket = self.new_rocket(at, stack, false);
|
||||
rocket.velocity = velocity;
|
||||
rocket.angled = true;
|
||||
rocket.owner = owner;
|
||||
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
|
||||
self.rockets.push(rocket);
|
||||
}
|
||||
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
|
||||
@@ -197,6 +246,7 @@ impl Game<'_> {
|
||||
}
|
||||
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let mut rocket = self.new_rocket(at, stack, true);
|
||||
rocket.owner = Some(Who::Player);
|
||||
rocket.guest = self.lan_acting;
|
||||
self.rockets.push(rocket);
|
||||
if !self.creative {
|
||||
@@ -206,10 +256,33 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
pub(super) fn tick_rockets(&mut self) {
|
||||
let targets = if self.rockets.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.rocket_targets()
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < self.rockets.len() {
|
||||
let mut r = self.rockets.remove(i);
|
||||
r.previous = r.position;
|
||||
// Projectile.tick: whether it has left its owner, before it moves.
|
||||
let owner = self.rocket_owner(&r);
|
||||
if !r.left_owner {
|
||||
let h = 0.125;
|
||||
let reach = Aabb::new(
|
||||
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
|
||||
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
|
||||
)
|
||||
.expand_towards(r.velocity)
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
r.left_owner = owner.is_none_or(|o| {
|
||||
!targets
|
||||
.iter()
|
||||
.any(|(who, b)| *who == o && b.intersects(reach))
|
||||
});
|
||||
}
|
||||
let skip = owner.filter(|_| !r.left_owner);
|
||||
let mut hit = None;
|
||||
if r.attached
|
||||
&& let Some(conn) = r.guest
|
||||
{
|
||||
@@ -235,7 +308,10 @@ impl Game<'_> {
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
hit = self.rocket_struck(&targets, &r, r.velocity, skip);
|
||||
} else {
|
||||
// getHitResultOnMoveVector before the move.
|
||||
hit = self.rocket_struck(&targets, &r, r.next_motion(), skip);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -246,6 +322,20 @@ impl Game<'_> {
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
}
|
||||
// hitTargetOrDeflectSelf on an entity: a breeze turns it back
|
||||
// (ProjectileDeflection.REVERSE), anything else sets it off.
|
||||
match hit {
|
||||
Some(Who::Mob(id))
|
||||
if self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == id && m.species() == Species::Breeze) =>
|
||||
{
|
||||
r.velocity = r.velocity.scale(-0.5);
|
||||
}
|
||||
Some(_) => r.life = r.lifetime,
|
||||
None => {}
|
||||
}
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
if r.life == 0 {
|
||||
self.audio.play(
|
||||
@@ -327,17 +417,28 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (id, amount) in hits {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.damage(
|
||||
amount,
|
||||
Damage {
|
||||
kind: DamageKind::Explosion,
|
||||
attacker: None,
|
||||
from: Some(at),
|
||||
},
|
||||
);
|
||||
// damageSources().fireworks(this, getOwner()): a guest's rocket
|
||||
// hurts as theirs, and the mobs it hit answer to them.
|
||||
let owner = r.owner;
|
||||
if let Some(conn) = r.guest.filter(|_| owner.is_some())
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
let ran = self
|
||||
.as_guest(&mut host, conn, |g| g.rocket_hurt_mobs(&hits, owner, at))
|
||||
.is_some();
|
||||
if ran {
|
||||
for (id, _) in &hits {
|
||||
if self.mobs.iter().any(|m| m.id == *id) {
|
||||
host.mob_players.insert(*id, conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
if !ran {
|
||||
self.rocket_hurt_mobs(&hits, owner, at);
|
||||
}
|
||||
} else {
|
||||
self.rocket_hurt_mobs(&hits, owner, at);
|
||||
}
|
||||
}
|
||||
let camera = self.eye_position().unwrap_or(at);
|
||||
@@ -345,6 +446,87 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
}
|
||||
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
|
||||
for &(id, amount) in hits {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.damage(
|
||||
amount,
|
||||
Damage {
|
||||
kind: DamageKind::Explosion,
|
||||
attacker,
|
||||
from: Some(at),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The owner as `rocket_targets` names it: a guest by their entity id.
|
||||
fn rocket_owner(&self, r: &Rocket) -> Option<Who> {
|
||||
let owner = r.owner?;
|
||||
let Some(conn) = r.guest.filter(|_| owner == Who::Player) else {
|
||||
return Some(owner);
|
||||
};
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
|
||||
Some(Who::Mob(g.entity_id as u32))
|
||||
}
|
||||
/// What a rocket can hit (`Projectile.canHitEntity`): living mobs, the
|
||||
/// dragon's parts and crystals, the player and the guests.
|
||||
fn rocket_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (Who::Mob(m.id), m.bounds()))
|
||||
.collect();
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
&& !self.spectator()
|
||||
{
|
||||
targets.push((Who::Player, p.bounds));
|
||||
}
|
||||
targets.extend(
|
||||
self.special_targets()
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
targets.extend(self.lan_projectile_targets());
|
||||
targets
|
||||
}
|
||||
/// `ProjectileUtil.getHitResultOnMoveVector`'s entity half: the nearest
|
||||
/// target on the move, short of the block it clips, each box grown by
|
||||
/// `computeMargin` (`tickCount` is the rocket's life plus one).
|
||||
fn rocket_struck(
|
||||
&self,
|
||||
targets: &[(Who, Aabb)],
|
||||
r: &Rocket,
|
||||
motion: Vec3,
|
||||
skip: Option<Who>,
|
||||
) -> Option<Who> {
|
||||
let from = r.position;
|
||||
let mut to = from.add_vector(motion);
|
||||
let w = self.mob_world_base(&[]);
|
||||
if let Some((at, ..)) = (super::creatures::ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
})
|
||||
.clip(from, to)
|
||||
{
|
||||
to = at;
|
||||
}
|
||||
let margin = (f64::from(r.life - 1) / 20.0).clamp(0.0, 0.3);
|
||||
targets
|
||||
.iter()
|
||||
.filter(|(who, _)| Some(*who) != skip)
|
||||
.filter_map(|(who, b)| {
|
||||
let at = b
|
||||
.inflate(Vec3::new(margin, margin, margin))
|
||||
.clip(from, to)?;
|
||||
Some((*who, from.distance_squared(at)))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(who, _)| who)
|
||||
}
|
||||
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
|
||||
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
|
||||
let camera = self.eye_position();
|
||||
|
||||
@@ -164,7 +164,7 @@ impl Game<'_> {
|
||||
(p.position.x - start[0]) as f32,
|
||||
(p.position.z - start[2]) as f32,
|
||||
);
|
||||
self.avatar.tick();
|
||||
self.avatar.tick(start);
|
||||
self.avatar
|
||||
.update_bob(ground && alive && !swimming, velocity);
|
||||
if riding {
|
||||
|
||||
@@ -324,12 +324,12 @@ impl Game<'_> {
|
||||
let Some(hit) = self.pick() else {
|
||||
return false;
|
||||
};
|
||||
let name = &*self
|
||||
let id = &*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
let name = name.trim_start_matches("minecraft:");
|
||||
let name = id.trim_start_matches("minecraft:");
|
||||
match name {
|
||||
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
|
||||
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
|
||||
@@ -338,8 +338,9 @@ impl Game<'_> {
|
||||
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
|
||||
| "end_portal" | "end_gateway" => true,
|
||||
// ChestBlock.getMenuProvider: none while a redstone conductor
|
||||
// sits on top of either half (a sitting cat aside).
|
||||
"chest" | "trapped_chest" => {
|
||||
// sits on top of either half (a sitting cat aside). Copper
|
||||
// chests are `ChestBlock`s too.
|
||||
_ if horizon_client::containers::is_chest_block(id) => {
|
||||
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let blocked = |p: (i32, i32, i32)| {
|
||||
let above = self.world.block(p.0, p.1 + 1, p.2);
|
||||
|
||||
@@ -425,6 +425,7 @@ impl Game<'_> {
|
||||
return result.and(stopped);
|
||||
}
|
||||
let mut next = Instant::now();
|
||||
let mut was_paused = false;
|
||||
while !flags.stop.load(Ordering::Relaxed) {
|
||||
self.run_tasks();
|
||||
if self.agent.hold_server {
|
||||
@@ -434,6 +435,14 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let paused = flags.paused.load(Ordering::Relaxed);
|
||||
if paused && !was_paused {
|
||||
// IntegratedServer.tickServer: the tick the game pauses saves
|
||||
// everything (saveEverything, without flushing), as an
|
||||
// autosave does.
|
||||
log::info!("Saving and pausing game...");
|
||||
self.save_game_with(false)?;
|
||||
}
|
||||
was_paused = paused;
|
||||
if paused {
|
||||
// Only the connections run.
|
||||
self.tick_server_timed(true)?;
|
||||
|
||||
@@ -458,6 +458,7 @@ impl Game<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn civil_dates_follow_the_gregorian_calendar() {
|
||||
@@ -465,4 +466,27 @@ mod tests {
|
||||
assert_eq!(civil(19_716), (2023, 12, 25));
|
||||
assert_eq!(civil(11_016), (2000, 2, 29));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shield_is_used_for_the_vanilla_s_time() {
|
||||
// Item.getUseDuration: an item with `blocks_attacks` (a shield) is
|
||||
// used for 72000 ticks, so its use animation (the arm held up)
|
||||
// runs instead of ending at once (#2289).
|
||||
let mut shield = Components::new();
|
||||
shield.insert("minecraft:blocks_attacks".into(), json!({}));
|
||||
assert_eq!(use_duration(&shield), 72_000);
|
||||
// Food is used for as long as it takes to eat it.
|
||||
let mut food = Components::new();
|
||||
food.insert(
|
||||
"minecraft:consumable".into(),
|
||||
json!({"consume_seconds": 1.6}),
|
||||
);
|
||||
assert_eq!(use_duration(&food), 32);
|
||||
// The bow keeps its own use time.
|
||||
let mut bow = Components::new();
|
||||
bow.insert("minecraft:item_model".into(), json!("minecraft:bow"));
|
||||
assert_eq!(use_duration(&bow), 72_000);
|
||||
// Anything else is not used at all.
|
||||
assert_eq!(use_duration(&Components::new()), 0);
|
||||
}
|
||||
}
|
||||
@@ -2152,6 +2152,7 @@ impl Game<'_> {
|
||||
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
|
||||
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
|
||||
m.extra.main_hand_left = g.info.main_hand == 0;
|
||||
m.extra.hidden_model_parts = !g.info.model_customisation & 0x7F;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
|
||||
@@ -833,35 +833,23 @@ impl Game<'_> {
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name;
|
||||
let (menu, title, slots) = if &**name == "minecraft:ender_chest" {
|
||||
let (menu, slots) = if &**name == "minecraft:ender_chest" {
|
||||
// EnderChestBlock.useWithoutItem: the player's own inventory.
|
||||
(
|
||||
"minecraft:generic_9x3",
|
||||
"container.enderchest",
|
||||
Slots::Ender,
|
||||
)
|
||||
("minecraft:generic_9x3", Slots::Ender)
|
||||
} else if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) {
|
||||
// AbstractFurnaceBlock.openContainer: FurnaceMenu, BlastFurnaceMenu, SmokerMenu.
|
||||
let (menu, title) = match f.kind.recipe_type() {
|
||||
"minecraft:blasting" => ("minecraft:blast_furnace", "container.blast_furnace"),
|
||||
"minecraft:smoking" => ("minecraft:smoker", "container.smoker"),
|
||||
_ => ("minecraft:furnace", "container.furnace"),
|
||||
let menu = match f.kind.recipe_type() {
|
||||
"minecraft:blasting" => "minecraft:blast_furnace",
|
||||
"minecraft:smoking" => "minecraft:smoker",
|
||||
_ => "minecraft:furnace",
|
||||
};
|
||||
(menu, title, Slots::Block(pos))
|
||||
(menu, Slots::Block(pos))
|
||||
} else if let Some(BlockEntity::Crafter(_)) = self.world.block_entities.get(&pos) {
|
||||
// CrafterBlock.useWithoutItem: CrafterMenu over the block entity.
|
||||
(
|
||||
"minecraft:crafter_3x3",
|
||||
"container.crafter",
|
||||
Slots::Block(pos),
|
||||
)
|
||||
("minecraft:crafter_3x3", Slots::Block(pos))
|
||||
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
|
||||
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
|
||||
(
|
||||
"minecraft:brewing_stand",
|
||||
"container.brewing",
|
||||
Slots::Block(pos),
|
||||
)
|
||||
("minecraft:brewing_stand", Slots::Block(pos))
|
||||
} else if let Some((other, right)) = self.chest_partner(pos) {
|
||||
// ChestBlock.combine: the right half's slots come first.
|
||||
let halves = if right {
|
||||
@@ -869,24 +857,33 @@ impl Game<'_> {
|
||||
} else {
|
||||
Slots::Double(other, pos)
|
||||
};
|
||||
("minecraft:generic_9x6", "container.chestDouble", halves)
|
||||
("minecraft:generic_9x6", halves)
|
||||
} else {
|
||||
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
|
||||
return false;
|
||||
};
|
||||
let (menu, title) = match items.len() {
|
||||
27 => ("minecraft:generic_9x3", "container.chest"),
|
||||
9 => ("minecraft:generic_3x3", "container.dispenser"),
|
||||
5 => ("minecraft:hopper", "container.hopper"),
|
||||
let menu = match items.len() {
|
||||
27 => "minecraft:generic_9x3",
|
||||
9 => "minecraft:generic_3x3",
|
||||
5 => "minecraft:hopper",
|
||||
_ => return false,
|
||||
};
|
||||
let title = match &**name {
|
||||
"minecraft:barrel" => "container.barrel",
|
||||
"minecraft:dropper" => "container.dropper",
|
||||
n if n.ends_with("shulker_box") => "container.shulkerBox",
|
||||
_ => title,
|
||||
};
|
||||
(menu, title, Slots::Block(pos))
|
||||
(menu, Slots::Block(pos))
|
||||
};
|
||||
// The menu provider's getDisplayName: a container's custom name
|
||||
// (BaseContainerBlockEntity.getName) or its default name.
|
||||
let custom = |k: (i32, i32, i32)| self.world.block_names.get(&k).map(String::as_str);
|
||||
let title = match slots {
|
||||
Slots::Ender => horizon_protocol::text::translatable("container.enderchest", &[]),
|
||||
Slots::Double(first, second) => {
|
||||
horizon_client::containers::double_chest_name(custom(first), custom(second))
|
||||
}
|
||||
Slots::Block(k) => {
|
||||
let Some(key) = horizon_client::containers::default_name_key(name) else {
|
||||
return false;
|
||||
};
|
||||
horizon_client::containers::display_name(custom(k), key)
|
||||
}
|
||||
};
|
||||
let Some(items) = host
|
||||
.hub
|
||||
@@ -898,14 +895,8 @@ impl Game<'_> {
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
host.hub.open_container(
|
||||
conn,
|
||||
menu,
|
||||
horizon_protocol::text::translatable(title, &[]),
|
||||
pos,
|
||||
slots,
|
||||
items,
|
||||
);
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1186,9 +1177,6 @@ impl Game<'_> {
|
||||
}
|
||||
S::UseItemOn(p) => {
|
||||
host.hub.ack(conn, p.sequence);
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
// handleUseItemOn: within reach, and the hit point within the block.
|
||||
let inside = p
|
||||
.cursor
|
||||
@@ -1331,6 +1319,9 @@ impl Game<'_> {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let old = g.menu_slot(slot);
|
||||
g.set_menu_slot(slot, item.clone());
|
||||
// InventoryMenu.setRemoteSlot: the client already has it, so broadcastChanges
|
||||
// sends nothing back that could undo a click made since.
|
||||
g.menu_sent[slot] = item.clone();
|
||||
// InventoryMenu's ArmorSlot and off-hand slot: setByPlayer.
|
||||
let equipment = match slot {
|
||||
5..=8 => Some(["head", "chest", "legs", "feet"][slot - 5]),
|
||||
@@ -1667,9 +1658,7 @@ impl Game<'_> {
|
||||
host.hub.teleport(gi, Some(at));
|
||||
}
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.death_time += 1;
|
||||
if g.death_time >= 20 {
|
||||
if host.hub.guests[gi].tick_death() {
|
||||
self.guest_drop_carried(host, gi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,9 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
if host.hub.guests[gi].spectator() {
|
||||
// handleContainerClick: nothing changes, and sendAllDataToRemote
|
||||
// takes back what the client predicted.
|
||||
host.hub.resend_menu(conn, p.container, &self.world);
|
||||
return true;
|
||||
}
|
||||
let mut menu = host
|
||||
|
||||
@@ -109,16 +109,6 @@ fn offset(p: Pos, d: Direction) -> Pos {
|
||||
let s = step(d);
|
||||
(p.0 + s[0], p.1 + s[1], p.2 + s[2])
|
||||
}
|
||||
fn clockwise(d: Direction) -> Direction {
|
||||
match d {
|
||||
Direction::North => Direction::East,
|
||||
Direction::East => Direction::South,
|
||||
Direction::South => Direction::West,
|
||||
Direction::West => Direction::North,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// State-level container queries shared by the game and comparator signals.
|
||||
struct Containers<'a> {
|
||||
world: &'a World,
|
||||
@@ -176,7 +166,7 @@ impl Containers<'_> {
|
||||
_ => Kind::Generic,
|
||||
};
|
||||
let size = entity.slots().len();
|
||||
if matches!(name, "minecraft:chest" | "minecraft:trapped_chest") {
|
||||
if horizon_client::containers::is_chest_block(name) {
|
||||
let is_blocked = |p: Pos| {
|
||||
let above = self.world.block(p.0, p.1 + 1, p.2);
|
||||
self.behaviours.facts(above).flags
|
||||
@@ -186,22 +176,19 @@ impl Containers<'_> {
|
||||
if blocked_chests && is_blocked(pos) {
|
||||
return None;
|
||||
}
|
||||
let kind_prop = self.prop(state, "type").unwrap_or("single");
|
||||
if kind_prop != "single"
|
||||
&& let Some(facing) = self.prop(state, "facing").and_then(dir_from_name)
|
||||
let half = |s: BlockStateId| {
|
||||
Some(horizon_client::containers::ChestHalf {
|
||||
block: self.name(s),
|
||||
kind: self.prop(s, "type")?,
|
||||
facing: self.prop(s, "facing")?,
|
||||
})
|
||||
};
|
||||
if let Some(own) = half(state)
|
||||
&& let Some((dx, dz)) = own.connected_step()
|
||||
{
|
||||
// ChestBlock.getConnectedDirection
|
||||
let connected = if kind_prop == "left" {
|
||||
clockwise(facing)
|
||||
} else {
|
||||
clockwise(clockwise(clockwise(facing)))
|
||||
};
|
||||
let other = offset(pos, connected);
|
||||
let other = (pos.0 + dx, pos.1, pos.2 + dz);
|
||||
let other_state = self.world.block(other.0, other.1, other.2);
|
||||
let paired = self.name(other_state) == name
|
||||
&& self
|
||||
.prop(other_state, "type")
|
||||
.is_some_and(|t| t != "single" && t != kind_prop)
|
||||
let paired = half(other_state).is_some_and(|o| own.pairs_with(&o))
|
||||
&& self
|
||||
.world
|
||||
.block_entities
|
||||
@@ -211,7 +198,7 @@ impl Containers<'_> {
|
||||
if blocked_chests && is_blocked(other) {
|
||||
return None;
|
||||
}
|
||||
let parts = if kind_prop == "right" {
|
||||
let parts = if own.kind == "right" {
|
||||
vec![(pos, size), (other, 27)]
|
||||
} else {
|
||||
vec![(other, 27), (pos, size)]
|
||||
|
||||
@@ -246,9 +246,15 @@ impl Game<'_> {
|
||||
self.paint_screen(batch, ox, oy, background, true);
|
||||
let atlas = &self.gui.atlas;
|
||||
let grey = rgba(64, 64, 64, 255);
|
||||
let title = self.container_title(self.translate("container.crafter", "Crafter"));
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(
|
||||
// A server's crafter menu has the title it was given.
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
horizon_client::text::Text::literal(
|
||||
self.container_title(self.translate("container.crafter", "Crafter")),
|
||||
)
|
||||
.resolve_legacy()
|
||||
});
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
ox + ((176.0 - tw) / 2.0).trunc(),
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::Game;
|
||||
use horizon_client::hud::HudBatch;
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
horizon_client::{hud::rgba, items::ItemStack, text::Text},
|
||||
horizon_client::{hud::rgba, items::ItemStack, text::Text, ui},
|
||||
horizon_mods::Shape,
|
||||
std::time::Instant,
|
||||
};
|
||||
@@ -108,7 +108,17 @@ impl Game<'_> {
|
||||
let r = s.rect;
|
||||
let name = s.sprite.strip_prefix("minecraft:").unwrap_or(&s.sprite);
|
||||
let name = format!("gui/sprites/{name}");
|
||||
batch.sprite_scaled(atlas, &name, ox + r.x, oy + r.y, r.w, r.h, argb(s.color));
|
||||
// As vanilla's blitSprite: a frame or a button keeps its border.
|
||||
ui::blit_sprite(
|
||||
batch,
|
||||
atlas,
|
||||
&name,
|
||||
ox + r.x,
|
||||
oy + r.y,
|
||||
r.w,
|
||||
r.h,
|
||||
argb(s.color),
|
||||
);
|
||||
}
|
||||
Shape::Item(i) => {
|
||||
if self.res.items().get(&i.item).is_none() {
|
||||
|
||||
@@ -142,13 +142,36 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// SkeletonHorse.mobInteract: nothing for an untamed one.
|
||||
if species == Species::SkeletonHorse && !m.extra.tame {
|
||||
return false;
|
||||
}
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
|
||||
// Horse / ZombieHorse.mobInteract: food is fed, and anything else in
|
||||
// hand makes an untamed one mad instead of seating the player.
|
||||
let dandelion = [self.held(), self.offhand.as_ref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|s| &*s.item == "minecraft:golden_dandelion");
|
||||
let mut events = Vec::new();
|
||||
match equine::held_item(
|
||||
&mut self.mobs[index],
|
||||
item,
|
||||
sneaking,
|
||||
dandelion,
|
||||
&mut events,
|
||||
) {
|
||||
Some(equine::HeldItem::Feed) => return self.feed_mount(index, item),
|
||||
Some(equine::HeldItem::Refused) => {
|
||||
let _ = self.handle_mob_events(events);
|
||||
return true;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let m = &self.mobs[index];
|
||||
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
|
||||
if !m.is_baby()
|
||||
&& m.extra.tame
|
||||
&& (self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight))
|
||||
{
|
||||
if !m.is_baby() && m.extra.tame && sneaking {
|
||||
self.open_mount_menu(index);
|
||||
return true;
|
||||
}
|
||||
@@ -489,6 +512,22 @@ impl Game<'_> {
|
||||
MobEvent::Buck { mob } => {
|
||||
if self.riding == Some(mob) {
|
||||
self.dismount_mount();
|
||||
} else {
|
||||
// ejectPassengers: a server's player gets off too
|
||||
// (leave_guest tells their client).
|
||||
let rider = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.vehicle == Some(mob))
|
||||
.map(|g| g.conn)
|
||||
});
|
||||
if let Some(conn) = rider
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
self.as_guest(&mut host, conn, |g| g.dismount_mount());
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
|
||||
self.mob_event_particles(mob, 6);
|
||||
|
||||
@@ -200,6 +200,9 @@ impl Game<'_> {
|
||||
self.sync_agent_control();
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
Event::SliderDrag => {
|
||||
self.apply_settings();
|
||||
}
|
||||
Event::CopyAgentDetails => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
let text = self.agent_details();
|
||||
@@ -269,7 +272,12 @@ impl Game<'_> {
|
||||
r.set_present_mode(s.vsync, s.horizon.present_mode);
|
||||
r.animated_textures = s.horizon.animated_textures;
|
||||
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
|
||||
r.set_world_post(s.horizon.resolution_scale as f32 / 100.0, s.horizon.fxaa);
|
||||
r.set_world_post(
|
||||
s.horizon.resolution_scale as f32 / 100.0,
|
||||
s.horizon.fxaa,
|
||||
s.horizon.taa,
|
||||
s.horizon.upscaling.ratio(),
|
||||
);
|
||||
r.set_hdr(
|
||||
s.horizon.hdr,
|
||||
s.horizon.hdr_paper_white as f32,
|
||||
|
||||
@@ -228,6 +228,11 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
if let Some(r) = entity_nbt::firework_rocket_from_nbt(&tag, player_uuid) {
|
||||
self.rockets.push(super::fireworks::Rocket::from_nbt(r));
|
||||
}
|
||||
}
|
||||
// PrimedTnt.readAdditionalSaveData: its fuse (80 when absent) and motion.
|
||||
"minecraft:tnt" => {
|
||||
if let Some(position) = entity_nbt::position(&tag) {
|
||||
|
||||
@@ -124,7 +124,8 @@ impl CameraType {
|
||||
}
|
||||
/// The player's model type (`PlayerModel` with the default skin).
|
||||
pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
horizon_client::mobs::load_model(root, "minecraft:player#main").map(|model| {
|
||||
let models = horizon_client::mobs::EntityModelDocument::load(root).ok()?;
|
||||
models.model("minecraft:player#main").map(|model| {
|
||||
Arc::new(MobType {
|
||||
id: "minecraft:player".into(),
|
||||
// Players render as a humanoid; the species only drives local AI.
|
||||
@@ -158,10 +159,14 @@ pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
ambient_interval: 80,
|
||||
flags: Default::default(),
|
||||
model: Arc::new(model),
|
||||
// PlayerModel's slim arms, for slim skins.
|
||||
layers: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
|
||||
.map(|slim| [("player_slim#main".to_owned(), Arc::new(slim))].into())
|
||||
.unwrap_or_default(),
|
||||
// PlayerModel's slim arms, for slim skins, and PlayerCapeModel.
|
||||
layers: ["player_slim#main", "player#cape"]
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let model = models.model(&format!("minecraft:{key}"))?;
|
||||
Some((key.to_owned(), Arc::new(model)))
|
||||
})
|
||||
.collect(),
|
||||
texture: "entity/player/wide/steve".into(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Humanoid { zombie_arms: false },
|
||||
@@ -357,6 +362,7 @@ impl Game<'_> {
|
||||
let off = self.offhand.as_ref().map(|s| s.item.clone());
|
||||
let riding = self.riding.is_some();
|
||||
let hurt = p.health.hurt_animation_time;
|
||||
let avatar = self.avatar;
|
||||
// LivingEntity.getBedOrientation: the model lies in the bed as
|
||||
// another player's does (`villager::sleep_pose`).
|
||||
let bed = self.sleeping.map_or(0, |head| self.bed_facing(head));
|
||||
@@ -424,6 +430,7 @@ impl Game<'_> {
|
||||
m.extra.fall_fly_ticks = fly_ticks;
|
||||
m.extra.auto_spin_attack = spinning;
|
||||
m.extra.main_hand_left = self.settings.main_hand_left;
|
||||
m.extra.hidden_model_parts = !self.settings.file.model_customisation() & 0x7F;
|
||||
m.extra.using_item = using;
|
||||
m.extra.using_off_hand = using_off_hand;
|
||||
m.extra.blocking = blocking;
|
||||
@@ -431,6 +438,13 @@ impl Game<'_> {
|
||||
m.extra.ints[5] = charge_duration;
|
||||
m.extra.elytra.tick(fall_flying, crouching, velocity);
|
||||
m.extra.swim.tick(visually_swimming, in_water);
|
||||
// The local player's ClientAvatarState, its cloak in the model's frame
|
||||
// (the model stands 0.125 lower while crouching).
|
||||
m.extra.avatar = avatar;
|
||||
if crouching {
|
||||
m.extra.avatar.cloak[1] -= 0.125;
|
||||
m.extra.avatar.cloak_o[1] -= 0.125;
|
||||
}
|
||||
m.extra.swing_right_arm = swing_right_arm;
|
||||
m.hurt_time = hurt;
|
||||
}
|
||||
@@ -482,14 +496,9 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
self.armor[1].as_ref().map(|s| &*s.item),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
|
||||
@@ -554,14 +563,9 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
&mut verts,
|
||||
);
|
||||
self.player_elytra_geometry(
|
||||
&m,
|
||||
self.armor[1].as_ref().map(|s| &*s.item),
|
||||
camera,
|
||||
1.0,
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
let height = if m.extra.crouching { 1.5 } else { 1.8 };
|
||||
// PictureInPictureRenderer: the box centre, (size, size, -size);
|
||||
@@ -620,9 +624,12 @@ impl Game<'_> {
|
||||
let Some((texture, _)) = assets.equipment.get(asset).and_then(|k| k.get("wings")) else {
|
||||
return;
|
||||
};
|
||||
// WingsLayer.getPlayerElytraTexture: a player's cape texture has its elytra.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let rect = m.cape.as_deref().and_then(|c| sprites.get(c));
|
||||
let (Some(model), Some(rect)) = (
|
||||
assets.player.get("elytra"),
|
||||
self.gui.atlas.sprites.get(texture),
|
||||
rect.or_else(|| sprites.get(texture)),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -649,6 +656,48 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `CapeLayer` for a player wearing `chest`: no cape under an elytra's
|
||||
/// wings, and moved out over a chestplate.
|
||||
pub(super) fn player_cape_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
chest: Option<&str>,
|
||||
camera: glam::DVec3,
|
||||
partial: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(rect) = m
|
||||
.cape
|
||||
.as_deref()
|
||||
.and_then(|c| self.gui.atlas.sprites.get(c))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if horizon_client::mobs::trader::is_invisible(m) {
|
||||
return;
|
||||
}
|
||||
let layers = chest
|
||||
.and_then(|item| self.res.items().get(item))
|
||||
.and_then(|i| i.equip_asset.as_ref())
|
||||
.and_then(|asset| self.armor_stand_assets.equipment.get(asset));
|
||||
if layers.is_some_and(|k| k.contains_key("wings")) {
|
||||
return;
|
||||
}
|
||||
let mut base = horizon_client::mobs::render::base_transform(m, partial, camera);
|
||||
if layers.is_some_and(|k| k.contains_key("humanoid")) {
|
||||
base *= glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.053125, 0.06875));
|
||||
}
|
||||
horizon_client::mobs::render::cape(
|
||||
m,
|
||||
partial,
|
||||
base,
|
||||
[rect.u0, rect.v0, rect.u1, rect.v1],
|
||||
light,
|
||||
self.res.mob_data(),
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `SpinAttackEffectLayer`: Riptide's two boxes turning around a spinning
|
||||
/// player (`SpinAttackEffectModel.setupAnim`).
|
||||
pub(super) fn spin_attack_geometry(
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::{Dim, Game, Mining, Screen};
|
||||
use horizon_client::first_person::{Hand, SwingAnimation};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
blocks::Fluid,
|
||||
chunk::Chunk,
|
||||
containers::{BlockEntity, Furnace, FurnaceKind},
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
@@ -141,11 +142,9 @@ pub(super) struct Remote {
|
||||
/// the first teleport, which takes them then.
|
||||
abilities: Option<play::PlayerAbilities>,
|
||||
loaded_sent: bool,
|
||||
/// The view distance last sent in ClientInformation.
|
||||
sent_view: i8,
|
||||
/// The Chat option's visibility last sent in ClientInformation
|
||||
/// (`Options.broadcastOptions` sends it again when it changes).
|
||||
sent_chat_visibility: i32,
|
||||
/// The ClientInformation last sent (`Options.broadcastOptions` sends it
|
||||
/// again when a view distance, Chat, skin or Main Hand option changes).
|
||||
sent_info: horizon_protocol::packets::configuration::ClientInformation,
|
||||
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
|
||||
/// with the sequence of the last action that changed them and the state the server
|
||||
/// last said they have. Until the server acknowledges that sequence its block updates
|
||||
@@ -192,6 +191,9 @@ pub(super) struct Remote {
|
||||
online_mode: bool,
|
||||
/// Player entities in the level: entity id to profile UUID.
|
||||
level_players: HashMap<i32, u128>,
|
||||
/// The profile name each player entity was created with (`RemotePlayer.getGameProfile`): it outlives the
|
||||
/// player-list entry, which servers remove to hide their NPCs from the list.
|
||||
level_player_names: HashMap<i32, String>,
|
||||
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
|
||||
/// theirs in `Mob::glowing`.
|
||||
glowing: std::collections::HashSet<i32>,
|
||||
@@ -216,8 +218,8 @@ pub(super) struct Remote {
|
||||
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
|
||||
show_death_screen: bool,
|
||||
player_type: Option<Arc<MobType>>,
|
||||
/// Inventory (then the off hand) as last synchronised, for creative-mode
|
||||
/// slot updates.
|
||||
/// Inventory, off hand and armour as last synchronised, for creative-mode
|
||||
/// slot updates (`creative_sync_stacks`).
|
||||
synced: Vec<Option<ItemStack>>,
|
||||
decode_errors: u32,
|
||||
clock: Option<i32>,
|
||||
@@ -476,8 +478,7 @@ impl Game<'_> {
|
||||
carried: None,
|
||||
abilities: None,
|
||||
loaded_sent: false,
|
||||
sent_view: view,
|
||||
sent_chat_visibility: self.settings.chat_visibility as i32,
|
||||
sent_info: self.settings.client_information(view),
|
||||
predictions: HashMap::new(),
|
||||
menu: None,
|
||||
menu_title: None,
|
||||
@@ -502,6 +503,7 @@ impl Game<'_> {
|
||||
listed: Default::default(),
|
||||
online_mode: false,
|
||||
level_players: HashMap::new(),
|
||||
level_player_names: HashMap::new(),
|
||||
glowing: Default::default(),
|
||||
profile: 0,
|
||||
profile_name: String::new(),
|
||||
@@ -516,7 +518,7 @@ impl Game<'_> {
|
||||
death_message: None,
|
||||
show_death_screen: true,
|
||||
player_type,
|
||||
synced: vec![None; 37],
|
||||
synced: vec![None; CREATIVE_SYNCED],
|
||||
decode_errors: 0,
|
||||
clock: None,
|
||||
clock_rate: 1.0,
|
||||
@@ -554,6 +556,12 @@ impl Game<'_> {
|
||||
self.server_packs_disconnect();
|
||||
self.code_of_conduct = None;
|
||||
self.close_dialogs();
|
||||
// Gui.setScreen -> KeyMapping.releaseAll: the title or disconnect
|
||||
// screen releases every held key. Its release goes to that screen,
|
||||
// so a key kept here would still be down in the next world (#3246).
|
||||
self.keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
@@ -1100,6 +1108,7 @@ impl Game<'_> {
|
||||
// The entity ids are the old world's; the new dimension's
|
||||
// player entities re-map to their profiles on AddEntity.
|
||||
r.level_players.clear();
|
||||
r.level_player_names.clear();
|
||||
// A new ClientLevel: a new chunk storage, centred on 0, 0.
|
||||
r.chunk_center = (0, 0);
|
||||
}
|
||||
@@ -1425,7 +1434,16 @@ impl Game<'_> {
|
||||
on_ground: bool,
|
||||
far_snaps: bool,
|
||||
) {
|
||||
let mob = self.mobs.iter_mut().find(|m| m.id == id as u32);
|
||||
// The dragon is drawn from its own state (game/dragon.rs), which
|
||||
// tick_remote_dragon eases to the server's moves through `lerps`. Its
|
||||
// copy in the mob list draws nothing: given the move, it kept the
|
||||
// dragon where it spawned (#2083).
|
||||
let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id);
|
||||
let mob = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id as u32)
|
||||
.filter(|_| !dragon);
|
||||
let tracked = self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -1791,13 +1809,12 @@ impl Game<'_> {
|
||||
self.streaming.radius =
|
||||
self.settings.render_distance.min(p.chunk_radius + 1).max(2);
|
||||
self.enter_remote_dimension(&p.spawn, false);
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
let info = self
|
||||
.settings
|
||||
.client_information(client_view_distance(self.settings.render_distance));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.sent_view = view;
|
||||
r.sent_info = info.clone();
|
||||
}
|
||||
let mut info =
|
||||
horizon_protocol::packets::configuration::ClientInformation::new(view);
|
||||
info.chat_visibility = self.settings.chat_visibility as i32;
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
@@ -1805,6 +1822,12 @@ impl Game<'_> {
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
// After a death (no KEEP_ENTITY_DATA) the new LocalPlayer
|
||||
// rides nothing: the server pairs a vehicle again with its
|
||||
// passengers. A dimension change keeps the vehicle it travels in.
|
||||
if p.keep & 2 == 0 {
|
||||
self.riding = None;
|
||||
}
|
||||
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
if dimension_changed {
|
||||
@@ -1942,11 +1965,18 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:player" {
|
||||
r.level_players.insert(p.id, p.uuid);
|
||||
// createEntityFromPacket: the player takes the profile of its list entry.
|
||||
match r.players.get(&p.uuid) {
|
||||
Some(name) => r.level_player_names.insert(p.id, name.clone()),
|
||||
None => r.level_player_names.remove(&p.id),
|
||||
};
|
||||
} else {
|
||||
r.level_players.remove(&p.id);
|
||||
r.level_player_names.remove(&p.id);
|
||||
}
|
||||
}
|
||||
// Entity.getTeam: a mob is on a team by its UUID.
|
||||
// Entity.getTeam: a mob is on a team by its UUID, a player by
|
||||
// its profile name (`Game::scoreboard_name`).
|
||||
self.persist.uuids.insert(
|
||||
p.id as u32,
|
||||
horizon_client::lan::playerdata::uuid_ints(p.uuid),
|
||||
@@ -1970,6 +2000,7 @@ impl Game<'_> {
|
||||
for id in p.ids {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.level_players.remove(&id);
|
||||
r.level_player_names.remove(&id);
|
||||
}
|
||||
self.remove_entity(id);
|
||||
}
|
||||
@@ -2161,6 +2192,13 @@ impl Game<'_> {
|
||||
{
|
||||
m.extra.main_hand_left = *arm == 0;
|
||||
}
|
||||
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
|
||||
// the player shows (PlayerModelPart bits).
|
||||
if let (16, Value::Byte(bits)) = (index, value)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
|
||||
@@ -2725,12 +2763,14 @@ impl Game<'_> {
|
||||
self.hotbar_pop[pop] = 5;
|
||||
}
|
||||
}
|
||||
let before = self.creative_stacks();
|
||||
self.set_menu_slot(p.container, i32::from(p.slot), stack);
|
||||
self.remote_synced();
|
||||
self.remote_synced_since(&before);
|
||||
}
|
||||
C::SetCursorItem(p) => self.carried = self.to_stack(&p.item),
|
||||
C::SetPlayerInventory(p) => {
|
||||
let stack = self.to_stack(&p.item);
|
||||
let before = self.creative_stacks();
|
||||
match p.slot {
|
||||
0..=35 => self.inventory[p.slot as usize] = stack,
|
||||
36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack,
|
||||
@@ -2738,7 +2778,7 @@ impl Game<'_> {
|
||||
40 => self.offhand = stack,
|
||||
_ => {}
|
||||
}
|
||||
self.remote_synced();
|
||||
self.remote_synced_since(&before);
|
||||
}
|
||||
C::ContainerSetData(p) if self.open_menu_name(p.container) == "minecraft:lectern" => {
|
||||
self.remote_lectern_page(i32::from(p.value));
|
||||
@@ -3214,6 +3254,8 @@ impl Game<'_> {
|
||||
let mut resolved: Vec<(u128, String)> = Vec::new();
|
||||
// PlayerSkin.model of each resolved skin: the textures' `slim`.
|
||||
let mut slim = std::collections::HashSet::new();
|
||||
// PlayerSkin.cape of each resolved skin.
|
||||
let mut capes: HashMap<u128, String> = HashMap::new();
|
||||
for (uuid, textures) in entries {
|
||||
// PlayerInfo.createSkinLookup: only the local player's own skin
|
||||
// may be unsigned.
|
||||
@@ -3227,10 +3269,24 @@ impl Game<'_> {
|
||||
if textures.slim {
|
||||
slim.insert(uuid);
|
||||
}
|
||||
if let Some(cape) = horizon_client::skins::cape(&textures) {
|
||||
capes.insert(uuid, cape);
|
||||
}
|
||||
resolved.push((uuid, skin));
|
||||
}
|
||||
}
|
||||
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
|
||||
// An offline name's cape from the launcher, where the profile has none.
|
||||
if !capes.contains_key(&local)
|
||||
&& let Some(rgba) = horizon_client::skins::offline_cape()
|
||||
{
|
||||
const OFFLINE: &str = "capes/offline";
|
||||
if !self.show_skin(OFFLINE) {
|
||||
self.store_skin(OFFLINE.into(), rgba.to_vec());
|
||||
}
|
||||
capes.insert(local, OFFLINE.into());
|
||||
}
|
||||
capes.retain(|_, cape| self.again_skin(cape));
|
||||
// PlayerInfo.getSkin: the downloaded skin and its model, else the
|
||||
// profile's default skin (DefaultPlayerSkin.get), slim or wide.
|
||||
let skin_of = |skins: &HashMap<u128, String>, uuid: u128| match skins.get(&uuid) {
|
||||
@@ -3262,12 +3318,18 @@ impl Game<'_> {
|
||||
{
|
||||
mob.skin = Some(skin);
|
||||
mob.slim = is_slim;
|
||||
mob.cape = capes.get(&uuid).cloned();
|
||||
}
|
||||
}
|
||||
if let Some(mob) = self.player_model.as_mut() {
|
||||
let (skin, is_slim) = skin_of(&skins, own);
|
||||
mob.skin = Some(skin);
|
||||
mob.slim = is_slim;
|
||||
// Avatar.isModelPartShown(CAPE): the Skin Customization option.
|
||||
mob.cape = capes
|
||||
.get(&own)
|
||||
.filter(|_| self.settings.file.bool("modelPart_cape"))
|
||||
.cloned();
|
||||
}
|
||||
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
|
||||
// mannequin's DATA_PROFILE `textures` property resolves like a
|
||||
@@ -3351,7 +3413,7 @@ impl Game<'_> {
|
||||
self.gui.atlas.sprites.remove(&old);
|
||||
slot
|
||||
};
|
||||
let Some(rect) = self
|
||||
let Some(mut rect) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
@@ -3368,6 +3430,10 @@ impl Game<'_> {
|
||||
height: 64,
|
||||
rgba,
|
||||
});
|
||||
// A cape is a 64x32 texture in the top half of the slot (skins::process_cape).
|
||||
if location.starts_with("capes/") {
|
||||
rect.v1 = (rect.v0 + rect.v1) / 2.0;
|
||||
}
|
||||
self.gui.atlas.sprites.insert(location.clone(), rect);
|
||||
self.skin_slots.push((location, slot));
|
||||
}
|
||||
@@ -3654,16 +3720,14 @@ impl Game<'_> {
|
||||
self.tick_bundle_hover();
|
||||
// Options.broadcastOptions: a changed render distance or Chat option
|
||||
// is sent again.
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
let visibility = self.settings.chat_visibility as i32;
|
||||
let info = self
|
||||
.settings
|
||||
.client_information(client_view_distance(self.settings.render_distance));
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.joined
|
||||
&& (r.sent_view != view || r.sent_chat_visibility != visibility)
|
||||
&& r.sent_info != info
|
||||
{
|
||||
r.sent_view = view;
|
||||
r.sent_chat_visibility = visibility;
|
||||
let mut info = horizon_protocol::packets::configuration::ClientInformation::new(view);
|
||||
info.chat_visibility = visibility;
|
||||
r.sent_info = info.clone();
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
|
||||
@@ -3827,6 +3891,17 @@ impl Game<'_> {
|
||||
// player too (controlled_simulation_tick does it otherwise): left
|
||||
// at 10, the hurt tilt restarted every frame and shook the camera.
|
||||
player.health.advance_clock();
|
||||
if riding && !dead {
|
||||
MotionWorld {
|
||||
entities: &entity_collisions,
|
||||
player: &mut *player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.passenger_pose_tick()?;
|
||||
}
|
||||
}
|
||||
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
|
||||
// jump, a spectator, landing) is sent to the server.
|
||||
@@ -4441,41 +4516,44 @@ impl Game<'_> {
|
||||
{
|
||||
out.push(packet);
|
||||
}
|
||||
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot);
|
||||
// `synced[36]` is the off hand.
|
||||
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
|
||||
let mut creative_slots = Vec::new();
|
||||
if self.creative {
|
||||
for i in 0..37 {
|
||||
let stack = self.inventory.get(i).unwrap_or(&self.offhand);
|
||||
if *stack != r.synced[i] {
|
||||
let stacks = creative_sync_stacks(&self.inventory, &self.offhand, &self.armor);
|
||||
for (i, stack) in stacks.into_iter().enumerate() {
|
||||
if stack != r.synced[i] {
|
||||
r.synced[i] = stack.clone();
|
||||
creative_slots.push(i);
|
||||
creative_slots.push((creative_sync_slot(i), stack));
|
||||
}
|
||||
}
|
||||
}
|
||||
for packet in out {
|
||||
self.send(packet);
|
||||
}
|
||||
for i in creative_slots {
|
||||
// InventoryMenu's slots: the hotbar is 36..=44, the off hand 45.
|
||||
let slot = match i {
|
||||
0..9 => 36 + i,
|
||||
36 => 45,
|
||||
_ => i,
|
||||
} as i16;
|
||||
let item = self.to_network(self.inventory.get(i).unwrap_or(&self.offhand));
|
||||
for (slot, stack) in creative_slots {
|
||||
let item = self.to_network(&stack);
|
||||
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot,
|
||||
item,
|
||||
}));
|
||||
}
|
||||
}
|
||||
fn creative_stacks(&self) -> Vec<Option<ItemStack>> {
|
||||
creative_sync_stacks(&self.inventory, &self.offhand, &self.armor)
|
||||
}
|
||||
/// Inventory as the server last described it.
|
||||
fn remote_synced(&mut self) {
|
||||
let mut inventory = self.inventory.clone();
|
||||
inventory.push(self.offhand.clone());
|
||||
let stacks = self.creative_stacks();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.synced = inventory;
|
||||
r.synced = stacks;
|
||||
}
|
||||
}
|
||||
/// The slots a server packet changed since `before` are as the server has them; the
|
||||
/// others keep what was last sent, so a creative click made since still goes out.
|
||||
fn remote_synced_since(&mut self, before: &[Option<ItemStack>]) {
|
||||
let now = self.creative_stacks();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
mark_synced(&mut r.synced, before, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4748,10 +4826,17 @@ impl Game<'_> {
|
||||
.and_then(|h| self.res.items().get(&h.item))
|
||||
.is_some_and(|i| i.block.is_some());
|
||||
if let Some(hit) = self.pick() {
|
||||
let (reacts, changes) = if hand == Hand::Main {
|
||||
let spectator = self.spectator();
|
||||
let (reacts, changes) = if hand == Hand::Main && !spectator {
|
||||
self.remote_block_reacts(&hit, held.as_ref())
|
||||
} else {
|
||||
(false, DryUse::default())
|
||||
// useItemOn runs for the off hand too (useWithoutItem doesn't):
|
||||
// RespawnAnchorBlock.useItemOn charges an anchor with its glowstone.
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
|
||||
let charges =
|
||||
!(sneaking && hands_full) && self.anchor_takes(hit.pos, held.as_ref());
|
||||
(charges, DryUse::default())
|
||||
};
|
||||
let seq = self.next_sequence();
|
||||
let cursor = [
|
||||
@@ -4768,6 +4853,12 @@ impl Game<'_> {
|
||||
world_border: false,
|
||||
sequence: seq,
|
||||
}));
|
||||
// MultiPlayerGameMode.performUseItemOn: a spectator's use predicts
|
||||
// nothing and ends the click (CONSUME, no swing); the server opens
|
||||
// the block's menu.
|
||||
if spectator {
|
||||
return true;
|
||||
}
|
||||
// Only a predicted placement (predict_place) marks a cell whose
|
||||
// resend plays a place sound: an empty hand or a non-block item
|
||||
// places nothing (BlockItem.place is where the sound comes from).
|
||||
@@ -4916,6 +5007,12 @@ impl Game<'_> {
|
||||
if self.jukebox_takes(hit.pos, held) {
|
||||
return (true, DryUse::default());
|
||||
}
|
||||
// RespawnAnchorBlock.useItemOn: PASS when the off hand's glowstone is to
|
||||
// charge the anchor, so useWithoutItem doesn't run and the off hand's turn
|
||||
// comes (glowstone in this hand charges it in `use_item_on`).
|
||||
if !self.anchor_takes(hit.pos, held) && self.anchor_takes(hit.pos, self.offhand.as_ref()) {
|
||||
return (false, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
// no UseItem follows (the server does the harvest, see `use_beehive`).
|
||||
@@ -5020,7 +5117,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItem for `hand`, if it holds something: whether
|
||||
/// the click ends there, as far as this client can tell (`item_has_use`).
|
||||
/// the click ends there, as far as this client can tell (`use_ends_click`).
|
||||
fn remote_use_item(&mut self, hand: Hand) -> bool {
|
||||
let Some(held) = self.with_hand(hand, |g| g.held().cloned()) else {
|
||||
return false;
|
||||
@@ -5049,6 +5146,12 @@ impl Game<'_> {
|
||||
if info.food.is_some() && !eating {
|
||||
return false;
|
||||
}
|
||||
// A bucket, a glass bottle or a boat with nothing to fill, empty or
|
||||
// place PASSes or FAILs: startUseItem goes on to the off hand (a
|
||||
// Riptide trident there charges).
|
||||
if self.with_hand(hand, |g| g.use_finds_no_target(&held)) {
|
||||
return false;
|
||||
}
|
||||
// It runs the use here too: ItemStack.use starts the `use_cooldown`
|
||||
// of an item with no use time, and the server's
|
||||
// ClientboundCooldownPacket confirms it; one held in use starts it at
|
||||
@@ -5060,9 +5163,65 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
self.with_hand(hand, |g| g.remote_start_held_use(&held));
|
||||
item_has_use(&info)
|
||||
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
|
||||
use_ends_click(&info, gliding)
|
||||
}
|
||||
|
||||
/// Whether `held`'s `Item.use` needs a target this client sees none of
|
||||
/// (not SUCCESS): BucketItem.use (a fluid source or powder snow to pick
|
||||
/// up through `ClipContext.Fluid.SOURCE_ONLY`, a block to empty a full
|
||||
/// one against, `mayUseItemAt`), BottleItem.use (water to fill from) and
|
||||
/// BoatItem.use (a block or fluid surface with room for the boat).
|
||||
fn use_finds_no_target(&self, held: &ItemStack) -> bool {
|
||||
let id = &*held.item;
|
||||
let facts = |pos: [i32; 3]| {
|
||||
self.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
};
|
||||
let source = |want: Option<Fluid>| {
|
||||
let fluid_hit = self.pick_fluid();
|
||||
// pick_fluid steps through waterlogged blocks, but their water is
|
||||
// a source the SOURCE_ONLY ray stops at (and a BucketPickup).
|
||||
if let Some(hit) = self.pick()
|
||||
&& facts(hit.pos).waterlogged
|
||||
{
|
||||
let eye = self.aim().map_or(hit.point, |(eye, _)| eye);
|
||||
let d = |p: [f64; 3]| (0..3).map(|a| (p[a] - eye[a]).powi(2)).sum::<f64>();
|
||||
if fluid_hit
|
||||
.as_ref()
|
||||
.is_none_or(|fluid| d(fluid.point) >= d(hit.point))
|
||||
{
|
||||
return want.is_none_or(|want| want == Fluid::Water);
|
||||
}
|
||||
}
|
||||
fluid_hit.is_some_and(|hit| {
|
||||
let f = facts(hit.pos);
|
||||
let fluid = f.fluid != Fluid::None && f.fluid_level == 8 && !f.fluid_falling;
|
||||
match want {
|
||||
Some(want) => fluid && f.fluid == want,
|
||||
None => fluid || self.is_powder_snow(hit.pos),
|
||||
}
|
||||
})
|
||||
};
|
||||
// SolidBucketItem only has useOn: its use is Item.use's PASS.
|
||||
if id == "minecraft:powder_snow_bucket" {
|
||||
return true;
|
||||
}
|
||||
if id == "minecraft:bucket" {
|
||||
return !self.may_build() || !source(None);
|
||||
}
|
||||
if horizon_client::items::bucket_contents(id).is_some() {
|
||||
return !self.may_build() || self.pick().is_none();
|
||||
}
|
||||
if id == "minecraft:glass_bottle" {
|
||||
return !source(Some(Fluid::Water));
|
||||
}
|
||||
if super::boats::any_boat_wood(id).is_some() {
|
||||
return self.boat_to_place(held).is_none();
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `Item.use` runs on the client too (MultiPlayerGameMode.useItem): an
|
||||
/// item held in use until the button lets go starts being used here, so
|
||||
/// that letting go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and
|
||||
@@ -5092,6 +5251,9 @@ impl Game<'_> {
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// EnderEyeItem.use: startUsingItem unless an End portal frame is
|
||||
// aimed at, with or without a stronghold to throw the eye at.
|
||||
"minecraft:ender_eye" => !self.aims_at_end_portal_frame(),
|
||||
// Item.use: a kinetic weapon starts being used.
|
||||
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
|
||||
// ShieldItem.use: anything that blocks attacks, off cooldown.
|
||||
@@ -5105,11 +5267,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
|
||||
/// crossbows, tridents, spyglasses, shields, eyes of ender, whose
|
||||
/// `getUseDuration` 0 never counts down to the end): its use only counts
|
||||
/// ticks here.
|
||||
fn held_until_released(&self, stack: &ItemStack) -> bool {
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
|
||||
"minecraft:bow"
|
||||
| "minecraft:crossbow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:spyglass"
|
||||
| "minecraft:ender_eye"
|
||||
) || horizon_client::spear::Kinetic::of(&stack.item).is_some()
|
||||
|| self.blocks_attacks(stack).is_some()
|
||||
}
|
||||
@@ -5593,6 +5761,14 @@ impl Game<'_> {
|
||||
} else {
|
||||
Input::Pickup
|
||||
};
|
||||
if self.screen == Screen::Creative {
|
||||
// CreativeModeInventoryScreen.slotClicked: the Survival Inventory tab clicks the
|
||||
// player's own InventoryMenu, and its CreativeInventoryListener sends each slot that
|
||||
// changed (SetCreativeModeSlot). No ContainerClick: a server holding the carried
|
||||
// stack would give it back after the Destroy Item slot cleared it (#3307).
|
||||
self.menu_click(Some(internal), i32::from(right), input);
|
||||
return;
|
||||
}
|
||||
self.remote_menu_ops(&[(Some(internal), i32::from(right), input)]);
|
||||
}
|
||||
/// The server's open menu: its window id and menu type (0, "inventory"
|
||||
@@ -5749,6 +5925,12 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.carried.take().expect("carried")
|
||||
};
|
||||
self.remote_creative_throw(thrown);
|
||||
}
|
||||
/// `MultiPlayerGameMode.handleCreativeModeItemDrop`: a stack thrown out of the creative
|
||||
/// screen goes to the server as a SetCreativeModeSlot to slot -1, which drops it from the
|
||||
/// player's hand; the client only swings (`Player.drop` on a client).
|
||||
pub(super) fn remote_creative_throw(&mut self, thrown: ItemStack) {
|
||||
if !self.creative {
|
||||
return;
|
||||
}
|
||||
@@ -6094,9 +6276,18 @@ impl Game<'_> {
|
||||
let r = self.remote.as_ref()?;
|
||||
Some(r.glowing.contains(&(id as i32)))
|
||||
}
|
||||
/// The profile name of another player the server sent, by entity id.
|
||||
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = r.level_players.get(&(id as i32))?;
|
||||
if *uuid == r.profile {
|
||||
return None;
|
||||
}
|
||||
r.level_player_name(id as i32, uuid)
|
||||
}
|
||||
/// Status for `horizon-ctl status`.
|
||||
/// The other players' entities in the level and their names (from the
|
||||
/// player list).
|
||||
/// The other players' entities in the level and their names (their
|
||||
/// profiles', which outlive the player list's entries).
|
||||
pub(super) fn remote_player_names(&self) -> Vec<(u32, String)> {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return Vec::new();
|
||||
@@ -6104,7 +6295,7 @@ impl Game<'_> {
|
||||
r.level_players
|
||||
.iter()
|
||||
.filter(|(_, uuid)| **uuid != r.profile)
|
||||
.filter_map(|(id, uuid)| Some((*id as u32, r.players.get(uuid)?.clone())))
|
||||
.filter_map(|(id, uuid)| Some((*id as u32, r.level_player_name(*id, uuid)?.to_owned())))
|
||||
.collect()
|
||||
}
|
||||
/// `ClientClockManager.tick`: the clock moves on by the game time that
|
||||
@@ -6240,6 +6431,50 @@ type Predicted = (
|
||||
Option<ItemStack>,
|
||||
);
|
||||
|
||||
/// How many stacks the creative screen keeps in step with the server (`Remote::synced`).
|
||||
const CREATIVE_SYNCED: usize = 41;
|
||||
|
||||
/// The player's stacks in `Remote::synced` order: the 36 inventory slots, the off hand, then
|
||||
/// the armour from the head down. `CreativeInventoryListener.slotChanged` sends every
|
||||
/// InventoryMenu slot the creative screen changes, the armour slots too.
|
||||
fn creative_sync_stacks(
|
||||
inventory: &[Option<ItemStack>],
|
||||
offhand: &Option<ItemStack>,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<Option<ItemStack>> {
|
||||
let mut stacks: Vec<Option<ItemStack>> = inventory.iter().take(36).cloned().collect();
|
||||
stacks.resize(36, None);
|
||||
stacks.push(offhand.clone());
|
||||
stacks.extend(armor.iter().cloned());
|
||||
stacks
|
||||
}
|
||||
|
||||
/// Entries of `creative_sync_stacks` that went from `before` to `now` are synced as `now`.
|
||||
fn mark_synced(
|
||||
synced: &mut [Option<ItemStack>],
|
||||
before: &[Option<ItemStack>],
|
||||
now: Vec<Option<ItemStack>>,
|
||||
) {
|
||||
for (i, stack) in now.into_iter().enumerate() {
|
||||
if before.get(i) != Some(&stack)
|
||||
&& let Some(s) = synced.get_mut(i)
|
||||
{
|
||||
*s = stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// InventoryMenu's slot for entry `i` of `creative_sync_stacks`: the hotbar is 36..=44, the
|
||||
/// main inventory 9..=35, the off hand 45 and the armour 5..=8 (head first).
|
||||
fn creative_sync_slot(i: usize) -> i16 {
|
||||
(match i {
|
||||
0..9 => 36 + i,
|
||||
36 => 45,
|
||||
37.. => 5 + (i - 37),
|
||||
_ => i,
|
||||
}) as i16
|
||||
}
|
||||
|
||||
fn menu_items(m: &horizon_client::menu::Menu) -> Vec<Option<ItemStack>> {
|
||||
(0..m.slot_count()).map(|i| m.slot(i).cloned()).collect()
|
||||
}
|
||||
@@ -6377,6 +6612,13 @@ fn hand_id(hand: Hand) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `Item.use` of `info` ends `Minecraft.startUseItem`'s click, so the
|
||||
/// off hand gets no turn. `FireworkRocketItem.use` answers SUCCESS while the
|
||||
/// player glides (the rocket boosts them) and PASS otherwise.
|
||||
fn use_ends_click(info: &horizon_client::items::ItemInfo, fall_flying: bool) -> bool {
|
||||
item_has_use(info) || (fall_flying && &*info.id == "minecraft:firework_rocket")
|
||||
}
|
||||
|
||||
/// Whether `Item.use` usually acts on this item (eats, drinks, equips, raises,
|
||||
/// throws, shoots, fills or places), which ends `Minecraft.startUseItem` at
|
||||
/// its hand. Tools and materials pass, and the off hand gets its turn. The
|
||||
@@ -6499,6 +6741,14 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// A player entity's profile name: the one it was created with, else its
|
||||
/// player-list entry's (an entity the list named after it spawned).
|
||||
fn level_player_name(&self, id: i32, uuid: &u128) -> Option<&str> {
|
||||
self.level_player_names
|
||||
.get(&id)
|
||||
.or_else(|| self.players.get(uuid))
|
||||
.map(String::as_str)
|
||||
}
|
||||
/// Players in the server's player list, this one included.
|
||||
pub(super) fn player_count(&self) -> usize {
|
||||
self.players.len()
|
||||
@@ -6624,6 +6874,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_creative_sync_covers_inventory_menu_slots_5_to_45_once_each() {
|
||||
// CreativeInventoryListener sends every InventoryMenu slot but the crafting ones: the
|
||||
// armour (5..=8), the inventory (9..=44) and the off hand (45).
|
||||
let stack = |item: &str| Some(horizon_client::items::ItemStack::new(item, 1));
|
||||
let inventory: Vec<_> = (0..36)
|
||||
.map(|i| stack(&format!("minecraft:item_{i}")))
|
||||
.collect();
|
||||
let offhand = stack("minecraft:shield");
|
||||
let armor = [
|
||||
stack("minecraft:diamond_helmet"),
|
||||
stack("minecraft:diamond_chestplate"),
|
||||
stack("minecraft:diamond_leggings"),
|
||||
stack("minecraft:diamond_boots"),
|
||||
];
|
||||
let stacks = super::creative_sync_stacks(&inventory, &offhand, &armor);
|
||||
assert_eq!(stacks.len(), super::CREATIVE_SYNCED);
|
||||
let mut slots: Vec<i16> = (0..stacks.len()).map(super::creative_sync_slot).collect();
|
||||
// Each entry goes to the menu slot that holds that stack.
|
||||
for (i, &slot) in slots.iter().enumerate() {
|
||||
let game_slot = Game::menu_slot("inventory", i32::from(slot)).unwrap();
|
||||
let expected = match game_slot {
|
||||
0..36 => &inventory[game_slot],
|
||||
504 => &offhand,
|
||||
500..504 => &armor[game_slot - 500],
|
||||
_ => panic!("entry {i} went to game slot {game_slot}"),
|
||||
};
|
||||
assert_eq!(&stacks[i], expected, "entry {i}, menu slot {slot}");
|
||||
}
|
||||
slots.sort();
|
||||
assert_eq!(slots, (5..=45).collect::<Vec<i16>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_slot_update_leaves_an_unsent_creative_click_to_be_sent() {
|
||||
let stack = |item: &str| Some(horizon_client::items::ItemStack::new(item, 1));
|
||||
let none = || vec![None; super::CREATIVE_SYNCED];
|
||||
// Synced: nothing. A click puts a helmet on (entry 37, not sent yet), then the server
|
||||
// sets hotbar slot 0 (a pickup): only that entry counts as synced.
|
||||
let mut synced = none();
|
||||
let mut before = none();
|
||||
before[37] = stack("minecraft:diamond_helmet");
|
||||
let mut now = before.clone();
|
||||
now[0] = stack("minecraft:stick");
|
||||
super::mark_synced(&mut synced, &before, now.clone());
|
||||
assert_eq!(synced[0], now[0]);
|
||||
assert_eq!(
|
||||
synced[37], None,
|
||||
"the helmet still differs, so the next tick sends it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_inventory_menu_slot_is_a_game_slot_and_45_is_the_off_hand() {
|
||||
// InventoryMenu: result, 2x2 grid, armour, 27 + 9 inventory slots, then the off hand.
|
||||
@@ -6659,4 +6961,31 @@ mod tests {
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
|
||||
assert_eq!(Game::menu_slot_index("unknown", 0), None);
|
||||
}
|
||||
|
||||
/// `FireworkRocketItem.use` is SUCCESS while gliding: the main hand's
|
||||
/// rocket ends the click and the off hand's shield is not raised (#3424).
|
||||
/// On foot it passes, so the off hand still gets its turn.
|
||||
#[test]
|
||||
fn a_rocket_used_while_gliding_ends_the_click() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping: no prepared resources in {}", root.display());
|
||||
return;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
let items = horizon_client::items::Items::load(&root, ®istry, &facts).unwrap();
|
||||
let rocket = items.get("minecraft:firework_rocket").unwrap();
|
||||
assert!(super::use_ends_click(rocket, true), "gliding");
|
||||
assert!(!super::use_ends_click(rocket, false), "on foot");
|
||||
// Other items answer the same whether or not the player glides.
|
||||
let shield = items.get("minecraft:shield").unwrap();
|
||||
let stick = items.get("minecraft:stick").unwrap();
|
||||
for gliding in [false, true] {
|
||||
assert!(super::use_ends_click(shield, gliding));
|
||||
assert!(!super::use_ends_click(stick, gliding));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,9 +118,28 @@ impl Game<'_> {
|
||||
.boss_overlay
|
||||
.darken_amount(self.clock.fraction() as f32),
|
||||
boss_world_fog: self.boss_overlay.should_create_world_fog(),
|
||||
spectator: self.spectator(),
|
||||
fire_resistance: self.potions.effects.get("fire_resistance").is_some(),
|
||||
night_vision: self.fog_night_vision(),
|
||||
},
|
||||
)
|
||||
}
|
||||
/// `FogRenderer.computeFogColor`'s night vision brightenFactor:
|
||||
/// `GameRenderer.nightVisionScale`, but not while Darkness is on.
|
||||
fn fog_night_vision(&self) -> f32 {
|
||||
let partial = self.clock.fraction() as f32;
|
||||
match self.potions.effects.get("night_vision") {
|
||||
Some(e) if self.potions.effects.get("darkness").is_none() => {
|
||||
// MobEffectInstance.endsWithin(200); -1 is infinite.
|
||||
if e.duration < 0 || e.duration > 200 {
|
||||
1.0
|
||||
} else {
|
||||
0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
/// `ClientLevelData.isFlat`: on the server thread, the world-wide flag.
|
||||
fn level_flat(&self) -> bool {
|
||||
match self.remote.as_ref() {
|
||||
|
||||
@@ -132,6 +132,15 @@ impl Game<'_> {
|
||||
.play("minecraft:block.respawn_anchor.charge", Some(at), 1.0, 1.0);
|
||||
Ok(())
|
||||
}
|
||||
/// `RespawnAnchorBlock.useItemOn`'s test: `held` is glowstone
|
||||
/// (`isRespawnFuel`) and the block at `pos` is an anchor that takes another
|
||||
/// charge (`canBeCharged`).
|
||||
pub(super) fn anchor_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
held.is_some_and(|s| &*s.item == "minecraft:glowstone")
|
||||
&& self.is_anchor(state)
|
||||
&& self.anchor_charges(state) < 4
|
||||
}
|
||||
/// `RespawnAnchorBlock.useItemOn` with `held` in the hand that acts.
|
||||
pub(super) fn use_respawn_anchor(
|
||||
&mut self,
|
||||
|
||||
@@ -85,27 +85,31 @@ impl Game<'_> {
|
||||
}
|
||||
self.score.storage.save(&self.world_root);
|
||||
self.score.stopwatches.save(&self.world_root);
|
||||
if self.border != horizon_client::worldborder::WorldBorder::default() {
|
||||
let _ = save::write_dat(
|
||||
if self.border != horizon_client::worldborder::WorldBorder::default()
|
||||
&& let Err(e) = save::write_dat(
|
||||
&self.border_path(),
|
||||
Tag::compound([
|
||||
("data", self.border.pack()),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]),
|
||||
);
|
||||
)
|
||||
{
|
||||
log::warn!("the world border was not written: {e}");
|
||||
}
|
||||
let path = self.scoreboard_path();
|
||||
let board = &self.score.board;
|
||||
if board == &Scoreboard::default() && !path.exists() {
|
||||
return;
|
||||
}
|
||||
let _ = save::write_dat(
|
||||
if let Err(e) = save::write_dat(
|
||||
&path,
|
||||
Tag::compound([
|
||||
("data", board.pack()),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]),
|
||||
);
|
||||
) {
|
||||
log::warn!("the scoreboard was not written: {e}");
|
||||
}
|
||||
}
|
||||
/// The names commands complete: objectives, teams and tracked holders.
|
||||
pub(super) fn sync_score_registries(&mut self) {
|
||||
@@ -462,10 +466,18 @@ impl Game<'_> {
|
||||
|
||||
// ---- teams in gameplay -----------------------------------------------------
|
||||
|
||||
/// `Entity.getTeam` of a mob (a mob joins a team by its UUID).
|
||||
/// `Entity.getScoreboardName`: another player's profile name
|
||||
/// (`Player.getScoreboardName`), any other entity's UUID.
|
||||
fn scoreboard_name(&self, id: u32, uuid: [i32; 4]) -> String {
|
||||
self.remote_player_name(id)
|
||||
.map_or_else(|| uuid_text(uuid), str::to_owned)
|
||||
}
|
||||
/// `Entity.getTeam` of a mob or another player.
|
||||
pub(super) fn mob_team(&self, id: u32) -> Option<&sb::Team> {
|
||||
let uuid = self.persist.uuids.get(&id)?;
|
||||
self.score.board.players_team(&uuid_text(*uuid))
|
||||
self.score
|
||||
.board
|
||||
.players_team(&self.scoreboard_name(id, *uuid))
|
||||
}
|
||||
pub(super) fn player_team(&self) -> Option<&sb::Team> {
|
||||
self.score.board.players_team(self.acting_player_name())
|
||||
@@ -482,7 +494,9 @@ impl Game<'_> {
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.filter_map(|(id, uuid)| Some((*id, board.players_team(&uuid_text(*uuid))?)))
|
||||
.filter_map(|(id, uuid)| {
|
||||
Some((*id, board.players_team(&self.scoreboard_name(*id, *uuid))?))
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -494,7 +508,7 @@ impl Game<'_> {
|
||||
self.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.filter(|(_, uuid)| team.players.contains(&uuid_text(**uuid)))
|
||||
.filter(|(id, uuid)| team.players.contains(&self.scoreboard_name(**id, **uuid)))
|
||||
.map(|(id, _)| *id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -366,6 +366,9 @@ impl Game<'_> {
|
||||
}
|
||||
// Autosaves keep a login position; death must resolve the respawn point instead.
|
||||
self.level.position = None;
|
||||
// The dead player's Entity.setRemoved stopped its riding: the
|
||||
// new one is not put back into the vehicle.
|
||||
self.riding = None;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.ui.death_text = None;
|
||||
|
||||
@@ -340,6 +340,9 @@ impl Loading {
|
||||
self.save_menu_settings();
|
||||
self.apply_menu_settings();
|
||||
}
|
||||
Event::SliderDrag => {
|
||||
self.apply_menu_settings();
|
||||
}
|
||||
Event::Close if self.menu.options.resource_error.is_some() => {}
|
||||
Event::Close => {
|
||||
self.menu.options_open = false;
|
||||
|
||||
@@ -1997,6 +1997,12 @@ impl Game<'_> {
|
||||
{
|
||||
push(&mut lines, text, rgba(85, 85, 255, 255));
|
||||
}
|
||||
// OminousBottleAmplifier.addToTooltip.
|
||||
if let Some((text, c)) =
|
||||
horizon_client::item_tooltip::ominous_bottle_line(stack, &display, &translate)
|
||||
{
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
// BlockItemStateProperties.addToTooltip.
|
||||
if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) {
|
||||
push(&mut lines, text, gray);
|
||||
|
||||
@@ -2,13 +2,62 @@
|
||||
//! kept in the dropped item's `container` component) and ender chests (the
|
||||
//! player's `PlayerEnderChestContainer`, saved as `EnderItems`).
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, ChestHalf, is_chest_block},
|
||||
items::ItemStack,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
pub(super) fn is_shulker_box(name: &str) -> bool {
|
||||
name.ends_with("shulker_box")
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayerGameMode.useItemOn` for a spectator: the block's menu
|
||||
/// (`BlockState.getMenuProvider`) opens, and nothing else happens. The
|
||||
/// lid, its sound and the `container_open` game event come from
|
||||
/// `ContainerOpenersCounter`, which spectators never join
|
||||
/// (`ChestBlockEntity.startOpen`); the stat, the piglins' anger and the
|
||||
/// shulker box's room check are `useWithoutItem`'s, which is not called.
|
||||
/// Only the block containers whose clicks the server takes back from a
|
||||
/// spectator (`handleContainerClick`) open here: storage, the furnaces,
|
||||
/// the brewing stand and the crafter.
|
||||
pub(super) fn spectator_open_menu(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name
|
||||
.clone();
|
||||
let container = is_shulker_box(&name)
|
||||
|| matches!(
|
||||
name.trim_start_matches("minecraft:"),
|
||||
"chest"
|
||||
| "trapped_chest"
|
||||
| "barrel"
|
||||
| "hopper"
|
||||
| "dispenser"
|
||||
| "dropper"
|
||||
| "furnace"
|
||||
| "blast_furnace"
|
||||
| "smoker"
|
||||
| "brewing_stand"
|
||||
| "crafter"
|
||||
);
|
||||
// ChestBlock.getMenuProvider: none while either half is blocked.
|
||||
if !container || self.chest_menu_blocked(key, &name) {
|
||||
return;
|
||||
}
|
||||
let Some(entity) = BlockEntity::for_block(&name) else {
|
||||
return;
|
||||
};
|
||||
self.world.block_entities.get_or_insert_with(key, || entity);
|
||||
self.container = Some(key);
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
}
|
||||
/// The custom name of the open container, or its default title
|
||||
/// (a double chest is named by whichever half has a name).
|
||||
pub(super) fn container_title(&self, default: String) -> String {
|
||||
@@ -72,7 +121,7 @@ impl Game<'_> {
|
||||
/// with either half blocked, opens no menu.
|
||||
pub(super) fn chest_menu_blocked(&self, key: (i32, i32, i32), name: &str) -> bool {
|
||||
match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" => {
|
||||
n if is_chest_block(n) => {
|
||||
self.chest_blocked_at(key)
|
||||
|| self
|
||||
.chest_partner(key)
|
||||
@@ -96,22 +145,13 @@ impl Game<'_> {
|
||||
|
||||
// ---- chest menus: double chests, titles and shift-click ----
|
||||
|
||||
/// `Direction.getClockWise` for horizontal facings.
|
||||
fn clockwise(facing: &str) -> &'static str {
|
||||
match facing {
|
||||
"north" => "east",
|
||||
"east" => "south",
|
||||
"south" => "west",
|
||||
_ => "north",
|
||||
}
|
||||
}
|
||||
fn counter_clockwise(facing: &str) -> &'static str {
|
||||
match clockwise(facing) {
|
||||
"east" => "west",
|
||||
"south" => "north",
|
||||
"west" => "east",
|
||||
_ => "south",
|
||||
}
|
||||
/// A chest block state's half: its block, `type` and `facing`.
|
||||
fn chest_half(state: &horizon_world::chunk::BlockState) -> Option<ChestHalf<'_>> {
|
||||
Some(ChestHalf {
|
||||
block: &state.name,
|
||||
kind: state.properties.get("type")?,
|
||||
facing: state.properties.get("facing")?,
|
||||
})
|
||||
}
|
||||
fn facing_step(facing: &str) -> (i32, i32) {
|
||||
match facing {
|
||||
@@ -128,34 +168,19 @@ impl Game<'_> {
|
||||
pub(super) fn chest_partner(&self, key: (i32, i32, i32)) -> Option<((i32, i32, i32), bool)> {
|
||||
let registry = self.pack.assets.registry;
|
||||
let state = registry.state(self.world.block(key.0, key.1, key.2))?;
|
||||
if !matches!(
|
||||
state.name.as_str(),
|
||||
"minecraft:chest" | "minecraft:trapped_chest"
|
||||
) {
|
||||
if !is_chest_block(&state.name) {
|
||||
return None;
|
||||
}
|
||||
let kind = state.properties.get("type")?.as_str();
|
||||
if kind == "single" {
|
||||
return None;
|
||||
}
|
||||
let facing = state.properties.get("facing")?.as_str();
|
||||
let dir = if kind == "left" {
|
||||
clockwise(facing)
|
||||
} else {
|
||||
counter_clockwise(facing)
|
||||
};
|
||||
let (dx, dz) = facing_step(dir);
|
||||
let own = chest_half(state)?;
|
||||
let (dx, dz) = own.connected_step()?;
|
||||
let other = (key.0 + dx, key.1, key.2 + dz);
|
||||
let other_state = registry.state(self.world.block(other.0, other.1, other.2))?;
|
||||
let other_kind = other_state.properties.get("type")?.as_str();
|
||||
let paired = other_state.name == state.name
|
||||
&& other_kind != "single"
|
||||
&& other_kind != kind
|
||||
let paired = own.pairs_with(&chest_half(other_state)?)
|
||||
&& matches!(
|
||||
self.world.block_entities.get(&other),
|
||||
Some(BlockEntity::Chest(items)) if items.len() == 27
|
||||
);
|
||||
paired.then_some((other, kind == "right"))
|
||||
paired.then_some((other, own.kind == "right"))
|
||||
}
|
||||
/// The open or close sound of a container block (`ChestBlockEntity.playSound`,
|
||||
/// `BarrelBlockEntity.playSound`, `ShulkerBoxBlockEntity.startOpen`): volume
|
||||
@@ -345,12 +370,8 @@ impl Game<'_> {
|
||||
return None;
|
||||
}
|
||||
let generic = match self.container_block().as_deref() {
|
||||
Some(
|
||||
"minecraft:chest"
|
||||
| "minecraft:trapped_chest"
|
||||
| "minecraft:barrel"
|
||||
| "minecraft:ender_chest",
|
||||
) => true,
|
||||
Some("minecraft:barrel" | "minecraft:ender_chest") => true,
|
||||
Some(n) if is_chest_block(n) => true,
|
||||
_ => self.open_cart_kind() == Some(super::minecarts::CartKind::Chest),
|
||||
};
|
||||
generic.then(|| self.container_size() / 9)
|
||||
@@ -394,8 +415,15 @@ impl Game<'_> {
|
||||
256.0,
|
||||
256.0,
|
||||
);
|
||||
let title = self.translate("container.shulkerBox", "Shulker Box");
|
||||
batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
// ShulkerBoxScreen draws the menu's title: the box's custom name
|
||||
// (BaseContainerBlockEntity.getDisplayName) or its default name.
|
||||
let title = self.remote_menu_title().unwrap_or_else(|| {
|
||||
Text::literal(
|
||||
self.container_title(self.translate("container.shulkerBox", "Shulker Box")),
|
||||
)
|
||||
.resolve_legacy()
|
||||
});
|
||||
batch.text_styled(atlas, &title, ox + 8.0, oy + 6.0, grey, false);
|
||||
let inventory = self.translate("container.inventory", "Inventory");
|
||||
batch.text(atlas, &inventory, ox + 8.0, oy + 73.0, grey, false);
|
||||
return;
|
||||
@@ -452,11 +480,38 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn double_chest_halves_connect_around_the_facing() {
|
||||
let half = |kind, facing| ChestHalf {
|
||||
block: "minecraft:chest",
|
||||
kind,
|
||||
facing,
|
||||
};
|
||||
// A left half facing north connects east, a right half west.
|
||||
assert_eq!(facing_step(clockwise("north")), (1, 0));
|
||||
assert_eq!(facing_step(counter_clockwise("north")), (-1, 0));
|
||||
assert_eq!(facing_step(clockwise("south")), (-1, 0));
|
||||
assert_eq!(facing_step(counter_clockwise("east")), (0, -1));
|
||||
assert_eq!(half("left", "north").connected_step(), Some((1, 0)));
|
||||
assert_eq!(half("right", "north").connected_step(), Some((-1, 0)));
|
||||
assert_eq!(half("left", "south").connected_step(), Some((-1, 0)));
|
||||
assert_eq!(half("right", "east").connected_step(), Some((0, -1)));
|
||||
assert_eq!(half("single", "east").connected_step(), None);
|
||||
}
|
||||
#[test]
|
||||
fn copper_chests_join_like_chests() {
|
||||
let half = |block, kind| ChestHalf {
|
||||
block,
|
||||
kind,
|
||||
facing: "north",
|
||||
};
|
||||
for block in [
|
||||
"minecraft:chest",
|
||||
"minecraft:trapped_chest",
|
||||
"minecraft:copper_chest",
|
||||
"minecraft:waxed_weathered_copper_chest",
|
||||
] {
|
||||
assert!(is_chest_block(block));
|
||||
assert!(half(block, "left").pairs_with(&half(block, "right")));
|
||||
}
|
||||
// ChestBlock.combine needs the same block on both halves.
|
||||
let copper = half("minecraft:copper_chest", "left");
|
||||
assert!(!copper.pairs_with(&half("minecraft:exposed_copper_chest", "right")));
|
||||
assert!(!is_chest_block("minecraft:ender_chest"));
|
||||
}
|
||||
#[test]
|
||||
fn shulker_boxes_are_recognised_by_name() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Server text displays: metadata, interpolation, billboard transforms and world text.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
gfx::TEXT_POLYGON_OFFSET_OVERLAY,
|
||||
hud::HudBatch,
|
||||
mesher::Vertex,
|
||||
text::Text,
|
||||
text_display::{Align, TextDisplayData},
|
||||
text_display::{self, Lines, TextDisplayData},
|
||||
};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
|
||||
@@ -34,12 +34,6 @@ pub(super) struct RemoteTextDisplay {
|
||||
lines: Option<Lines>,
|
||||
}
|
||||
|
||||
/// `TextDisplay.CachedInfo`: each line and its width, and the widest.
|
||||
struct Lines {
|
||||
lines: Vec<(Text, f32)>,
|
||||
width: f32,
|
||||
}
|
||||
|
||||
/// `Display.RENDER_STATE_IDS` and `TextDisplay.TEXT_RENDER_STATE_IDS`: the
|
||||
/// data a render state is built from (not the view range, size or glow).
|
||||
fn renders(id: u8) -> bool {
|
||||
@@ -157,6 +151,7 @@ impl Game<'_> {
|
||||
see_through: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.text_display_vertices = 0;
|
||||
self.mark_translucent_entity(normal.len(), "minecraft:text_display");
|
||||
let atlas = &self.gui.atlas;
|
||||
let front = self.camera_type == super::perspective::CameraType::ThirdPersonFront;
|
||||
let angles = if front {
|
||||
@@ -177,69 +172,23 @@ impl Game<'_> {
|
||||
display.old_rotation[i]
|
||||
+ angle(display.rotation[i] - display.old_rotation[i]) * partial as f32
|
||||
});
|
||||
let mut matrix = glam::Mat4::from_translation((position - camera).as_vec3())
|
||||
let lines = display
|
||||
.lines
|
||||
.get_or_insert_with(|| Lines::split(&atlas.font, &d.text, d.line_width));
|
||||
let matrix = glam::Mat4::from_translation((position - camera).as_vec3())
|
||||
* d.matrix(rotation, angles)
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(-0.025));
|
||||
* lines.pose();
|
||||
if !matrix.is_finite() {
|
||||
continue;
|
||||
}
|
||||
// TextDisplayRenderer.splitLines: Font.split, no indent on wrapped lines.
|
||||
let Lines { lines, width } = display.lines.get_or_insert_with(|| {
|
||||
let lines: Vec<_> = atlas
|
||||
.font
|
||||
.split(&d.text, d.line_width as f32)
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let width = atlas.font.width_text(&line).ceil();
|
||||
(line, width)
|
||||
})
|
||||
.collect();
|
||||
let width = lines.iter().map(|l| l.1).fold(0.0, f32::max);
|
||||
Lines { lines, width }
|
||||
});
|
||||
let width = *width;
|
||||
let height = lines.len() as f32 * 10.0 - 1.0;
|
||||
matrix *=
|
||||
glam::Mat4::from_translation(glam::Vec3::new(1.0 - width / 2.0, -height, 0.0));
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let bg = if d.flags & 4 != 0 {
|
||||
self.settings.text_background(0.25)
|
||||
} else {
|
||||
d.background as u32
|
||||
};
|
||||
if bg != 0 {
|
||||
batch.fill(
|
||||
atlas,
|
||||
// submitTextBackground(-1, -1, width, height): corners, not a size.
|
||||
-1.0,
|
||||
-1.0,
|
||||
width + 1.0,
|
||||
height + 1.0,
|
||||
rgba(
|
||||
(bg >> 16) as u8,
|
||||
(bg >> 8) as u8,
|
||||
bg as u8,
|
||||
(bg >> 24) as u8,
|
||||
),
|
||||
);
|
||||
}
|
||||
let align = d.align();
|
||||
for (i, (line, line_width)) in lines.iter().enumerate() {
|
||||
let x = match align {
|
||||
Align::Left => 0.0,
|
||||
Align::Right => width - line_width,
|
||||
Align::Center => (width - line_width) / 2.0,
|
||||
};
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
x,
|
||||
i as f32 * 10.0,
|
||||
rgba(255, 255, 255, d.opacity as u8),
|
||||
d.flags & 1 != 0,
|
||||
);
|
||||
}
|
||||
text_display::draw(
|
||||
&mut batch,
|
||||
atlas,
|
||||
&d,
|
||||
lines,
|
||||
self.settings.text_background(0.25),
|
||||
);
|
||||
let world_light = self.world.light(
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
@@ -251,11 +200,12 @@ impl Game<'_> {
|
||||
((d.brightness as u32 >> 20 & 15) * 16)
|
||||
| (((d.brightness as u32 >> 4 & 15) * 16) << 8)
|
||||
};
|
||||
// Font.DisplayMode: SEE_THROUGH over everything, else POLYGON_OFFSET.
|
||||
let through = d.flags & 2 != 0;
|
||||
let out = if through {
|
||||
&mut *see_through
|
||||
let (out, layer) = if through {
|
||||
(&mut *see_through, 2)
|
||||
} else {
|
||||
&mut *normal
|
||||
(&mut *normal, TEXT_POLYGON_OFFSET_OVERLAY)
|
||||
};
|
||||
for quad in batch.vertices.as_chunks::<6>().0 {
|
||||
for i in [0, 1, 2, 5] {
|
||||
@@ -267,7 +217,7 @@ impl Game<'_> {
|
||||
uv: v.uv,
|
||||
color: v.color,
|
||||
light,
|
||||
layer: if through { 2 } else { 0 },
|
||||
layer,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
|
||||
@@ -465,6 +465,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An ominous bottle's `ominous_bottle_amplifier` reaches the client
|
||||
/// through the packet's bytes, so its tooltip names the Bad Omen level.
|
||||
#[test]
|
||||
fn an_ominous_bottle_keeps_its_amplifier_across_the_wire() {
|
||||
use horizon_protocol::buf::{Codec, Reader, Writer};
|
||||
use horizon_protocol::item::Slot;
|
||||
let mut components = vec![String::new(); 80];
|
||||
components[component::OMINOUS_BOTTLE_AMPLIFIER as usize] =
|
||||
"minecraft:ominous_bottle_amplifier".into();
|
||||
let items = ["minecraft:air", "minecraft:ominous_bottle"];
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".to_owned(),
|
||||
Ids::from_names(items.iter().map(|s| s.to_string()).collect()),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".to_owned(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let dynamic = Dynamic {
|
||||
registries: Default::default(),
|
||||
};
|
||||
for amplifier in [None, Some(0), Some(3)] {
|
||||
let mut bottle = ItemStack::new("minecraft:ominous_bottle", 1);
|
||||
if let Some(a) = amplifier {
|
||||
bottle.data_mut().ominous_amplifier = Some(a);
|
||||
}
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| items.iter().position(|i| *i == name).unwrap_or(0) as i32,
|
||||
|_, _| None,
|
||||
&Some(bottle),
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&wire, &mut w);
|
||||
let mut r = Reader::new(&w.data);
|
||||
let read = Slot::read(&mut r).unwrap().unwrap();
|
||||
assert_eq!(r.remaining(), 0);
|
||||
let back = from_network(&read, &builtin, &dynamic);
|
||||
assert_eq!(
|
||||
back.data().and_then(|d| d.ominous_amplifier),
|
||||
amplifier,
|
||||
"{amplifier:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a creative client sends (`stack_to_network`) comes back whole on
|
||||
/// the server: damage, both enchantment lists, repair cost, name, trim.
|
||||
#[test]
|
||||
|
||||
@@ -100,11 +100,16 @@ fn modifier_line(
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// TranslatableContents: `%s` and numbered `%1$s` arguments, `%%` a percent sign.
|
||||
Some(
|
||||
text(&format!("attribute.modifier.{kind}.{operation}"))
|
||||
.replacen("%s", &format_amount(shown), 1)
|
||||
.replacen("%s", &text(&format!("attribute.name.{attribute}")), 1)
|
||||
.replace("%%", "%"),
|
||||
crate::text::format_translation(
|
||||
&text(&format!("attribute.modifier.{kind}.{operation}")),
|
||||
&[
|
||||
crate::text::Text::literal(format_amount(shown)),
|
||||
crate::text::Text::literal(text(&format!("attribute.name.{attribute}"))),
|
||||
],
|
||||
)
|
||||
.plain(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,7 +213,10 @@ fn is_potion_item(item: &str) -> bool {
|
||||
/// The name line of `stack`, as the item's translation or, for potions and
|
||||
/// smithing templates, its name component gives it.
|
||||
pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>) -> String {
|
||||
let path = stack.item.trim_start_matches("minecraft:");
|
||||
let (namespace, path) = stack
|
||||
.item
|
||||
.split_once(':')
|
||||
.unwrap_or(("minecraft", &stack.item));
|
||||
if let Some(name) = stack.custom_name() {
|
||||
return name.to_owned();
|
||||
}
|
||||
@@ -231,9 +239,12 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// `Item.getDescriptionId`: `Util.makeDescriptionId("item", id)`, or the
|
||||
// block's for a block item, in the item's own namespace.
|
||||
let id = format!("{namespace}.{}", path.replace('/', "."));
|
||||
key.and_then(|k| translate(&k))
|
||||
.or_else(|| translate(&format!("item.minecraft.{path}")))
|
||||
.or_else(|| translate(&format!("block.minecraft.{path}")))
|
||||
.or_else(|| translate(&format!("item.{id}")))
|
||||
.or_else(|| translate(&format!("block.{id}")))
|
||||
.unwrap_or_else(|| path.replace('_', " "))
|
||||
}
|
||||
|
||||
@@ -424,6 +435,44 @@ pub fn unbreakable_line(
|
||||
.then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into()))
|
||||
}
|
||||
|
||||
/// `OminousBottleAmplifier.addToTooltip`: the effect the bottle gives,
|
||||
/// `MobEffectInstance(BAD_OMEN, 120000, amplifier)`, listed as
|
||||
/// `PotionContents.addPotionTooltip` lists it, in the colour of Bad Omen's
|
||||
/// category (NEUTRAL, blue). An ominous bottle without the component has the
|
||||
/// item's default amplifier, 0.
|
||||
pub fn ominous_bottle_line(
|
||||
stack: &ItemStack,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<(String, u32)> {
|
||||
const BAD_OMEN_TICKS: i32 = 120_000;
|
||||
let amplifier = stack
|
||||
.data()
|
||||
.and_then(|d| d.ominous_amplifier)
|
||||
.or_else(|| (&*stack.item == "minecraft:ominous_bottle").then_some(0))?;
|
||||
if !display.shows("minecraft:ominous_bottle_amplifier") {
|
||||
return None;
|
||||
}
|
||||
use crate::text::{Text, format_translation};
|
||||
let text = |key: &str, fallback: &str| translate(key).unwrap_or_else(|| fallback.to_owned());
|
||||
// PotionContents.getPotionDescription.
|
||||
let mut line = Text::literal(text(
|
||||
"effect.minecraft.bad_omen",
|
||||
"effect.minecraft.bad_omen",
|
||||
));
|
||||
if amplifier > 0 {
|
||||
let potency = translate(&format!("potion.potency.{amplifier}"))
|
||||
.unwrap_or_else(|| crate::enchant::level_name(u32::from(amplifier) + 1));
|
||||
line = format_translation(
|
||||
&text("potion.withAmplifier", "%s %s"),
|
||||
&[line, Text::literal(potency)],
|
||||
);
|
||||
}
|
||||
let duration = Text::literal(crate::brewing::format_duration(BAD_OMEN_TICKS));
|
||||
let line = format_translation(&text("potion.withDuration", "%s (%s)"), &[line, duration]);
|
||||
Some((line.plain(), rgba(85, 85, 255, 255)))
|
||||
}
|
||||
|
||||
/// `PotionContents.addPotionTooltip`'s attribute block: an empty line, "When
|
||||
/// Applied:" and the modifiers of the potion's effects (blue for a gain, red
|
||||
/// for a loss); nothing for a potion whose effects add none.
|
||||
@@ -586,6 +635,7 @@ pub fn detail_lines(
|
||||
.map(|(line, _)| line),
|
||||
);
|
||||
}
|
||||
lines.extend(ominous_bottle_line(stack, &display, translate).map(|l| l.0));
|
||||
lines
|
||||
}
|
||||
|
||||
@@ -820,6 +870,62 @@ mod tests {
|
||||
assert!(!display.shows("minecraft:attribute_modifiers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ominous_bottle_lists_bad_omen_at_its_amplifier_for_100_minutes() {
|
||||
let lang = |key: &str| {
|
||||
Some(
|
||||
match key {
|
||||
"effect.minecraft.bad_omen" => "Bad Omen",
|
||||
"potion.withAmplifier" => "%s %s",
|
||||
"potion.withDuration" => "%s (%s)",
|
||||
"potion.potency.3" => "IV",
|
||||
_ => return None,
|
||||
}
|
||||
.to_owned(),
|
||||
)
|
||||
};
|
||||
let duration = crate::brewing::format_duration(120_000);
|
||||
let blue = rgba(85, 85, 255, 255);
|
||||
// The item's default amplifier, 0: no potency.
|
||||
let mut bottle = ItemStack::new("minecraft:ominous_bottle", 1);
|
||||
let line = |s: &ItemStack| ominous_bottle_line(s, &TooltipDisplay::for_stack(s), &lang);
|
||||
assert_eq!(
|
||||
line(&bottle),
|
||||
Some((format!("Bad Omen ({duration})"), blue))
|
||||
);
|
||||
bottle.data_mut().ominous_amplifier = Some(3);
|
||||
assert_eq!(
|
||||
line(&bottle),
|
||||
Some((format!("Bad Omen IV ({duration})"), blue))
|
||||
);
|
||||
assert!(
|
||||
lines(&bottle, None, &lang).contains(&format!("Bad Omen IV ({duration})")),
|
||||
"{:?}",
|
||||
lines(&bottle, None, &lang)
|
||||
);
|
||||
// A numbered template, as some languages write it.
|
||||
let numbered = |key: &str| match key {
|
||||
"potion.withDuration" => Some("%2$s: %1$s".to_owned()),
|
||||
other => lang(other),
|
||||
};
|
||||
assert_eq!(
|
||||
ominous_bottle_line(&bottle, &TooltipDisplay::for_stack(&bottle), &numbered)
|
||||
.map(|l| l.0),
|
||||
Some(format!("{duration}: Bad Omen IV"))
|
||||
);
|
||||
bottle.data_mut().set_other(
|
||||
"minecraft:tooltip_display",
|
||||
Some(Tag::compound([(
|
||||
"hidden_components",
|
||||
Tag::List(vec![Tag::String(
|
||||
"minecraft:ominous_bottle_amplifier".into(),
|
||||
)]),
|
||||
)])),
|
||||
);
|
||||
assert_eq!(line(&bottle), None);
|
||||
assert_eq!(line(&ItemStack::new("minecraft:glass_bottle", 1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ominous_banner_keeps_its_default_hidden_pattern_tooltip() {
|
||||
let mut banner = crate::mobs::raider::ominous_banner();
|
||||
@@ -862,6 +968,25 @@ mod tests {
|
||||
assert_eq!(rarity(&plain, "common"), "common");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_line_translates_items_of_any_namespace() {
|
||||
let translate = |key: &str| match key {
|
||||
"item.example.ruby" => Some("Ruby".to_owned()),
|
||||
"block.example.ruby_block" => Some("Block of Ruby".to_owned()),
|
||||
"item.example.tools.chisel" => Some("Chisel".to_owned()),
|
||||
"item.minecraft.diamond" => Some("Diamond".to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
let name = |id: &str| name_line(&ItemStack::new(id, 1), &translate);
|
||||
// `Util.makeDescriptionId`: the item's own namespace, `/` as `.`.
|
||||
assert_eq!(name("example:ruby"), "Ruby");
|
||||
assert_eq!(name("example:ruby_block"), "Block of Ruby");
|
||||
assert_eq!(name("example:tools/chisel"), "Chisel");
|
||||
assert_eq!(name("minecraft:diamond"), "Diamond");
|
||||
// No translation: the path, as the creative names fall back to.
|
||||
assert_eq!(name("example:raw_ruby"), "raw ruby");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_lines_list_five_stacks_then_how_many_more() {
|
||||
let mut chest = ItemStack::new("minecraft:chest", 1);
|
||||
@@ -1096,4 +1221,27 @@ mod tests {
|
||||
);
|
||||
assert_eq!(modifier_line(&lang, "movement_speed", 0.0, 2, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_lines_support_positional_format() {
|
||||
let lang = |k: &str| {
|
||||
Some(
|
||||
match k {
|
||||
"attribute.modifier.equals.0" => "%2$s: %1$s",
|
||||
"attribute.modifier.plus.2" => "+%1$s%% %2$s",
|
||||
"attribute.name.attack_damage" => "Damage",
|
||||
_ => return None,
|
||||
}
|
||||
.to_owned(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
modifier_line(&lang, "attack_damage", 5.0, 0, true).as_deref(),
|
||||
Some("Damage: 5")
|
||||
);
|
||||
assert_eq!(
|
||||
modifier_line(&lang, "attack_damage", 0.2, 2, false).as_deref(),
|
||||
Some("+20% Damage")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,24 @@ impl Hub {
|
||||
g.send(&packet);
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.sendAllDataToRemote` for the guest's open
|
||||
/// block container `window`: every slot and the carried stack again.
|
||||
pub fn resend_menu(&mut self, conn: u32, window: i32, world: &World) {
|
||||
let Some(i) = self.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let g = &self.guests[i];
|
||||
let Some(items) = g
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| window != 0 && m.window == window)
|
||||
.and_then(|m| m.slots.read(world, g))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.send_menu_content(i, items);
|
||||
}
|
||||
|
||||
fn close_container_at(&mut self, i: usize) {
|
||||
let g = &mut self.guests[i];
|
||||
if let Some(menu) = g.menu.take() {
|
||||
@@ -378,7 +396,13 @@ impl Hub {
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.window == p.container);
|
||||
if !window_ok || self.guests[i].spectator() {
|
||||
if !window_ok {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.guests[i].spectator() {
|
||||
// A spectator's click changes nothing, and sendAllDataToRemote
|
||||
// takes back what its client predicted.
|
||||
self.resend_menu(conn, p.container, world);
|
||||
return Vec::new();
|
||||
}
|
||||
// A menu that is no longer valid takes no clicks.
|
||||
|
||||
@@ -378,6 +378,22 @@ impl Guest {
|
||||
pub fn send(&self, packet: &play::Clientbound) {
|
||||
self.sender.send(packet.encode());
|
||||
}
|
||||
/// `Entity.stopRiding`: out of the vehicle it rode.
|
||||
pub fn stop_riding(&mut self) {
|
||||
(self.vehicle, self.vehicle_good) = (None, None);
|
||||
}
|
||||
/// `LivingEntity.tickDeath` for a dead player: true once its body is
|
||||
/// removed, 20 ticks after it died (`remove(KILLED)`, whose
|
||||
/// `Entity.setRemoved` stops its riding). The body sits in its vehicle
|
||||
/// until then.
|
||||
pub fn tick_death(&mut self) -> bool {
|
||||
self.death_time += 1;
|
||||
let removed = self.death_time >= 20;
|
||||
if removed {
|
||||
self.stop_riding();
|
||||
}
|
||||
removed
|
||||
}
|
||||
pub fn send_all(&self, packets: &[play::Clientbound]) {
|
||||
for p in packets {
|
||||
self.send(p);
|
||||
@@ -667,6 +683,12 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Int(i32::from(mode)),
|
||||
));
|
||||
}
|
||||
if let Some(amplifier) = d.ominous_amplifier {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::OMINOUS_BOTTLE_AMPLIFIER,
|
||||
horizon_protocol::item::Component::Int(i32::from(amplifier)),
|
||||
));
|
||||
}
|
||||
stack.added.extend(firework_components(d));
|
||||
// The compass's lodestone target.
|
||||
if let Some(t) = d.other("minecraft:lodestone_tracker") {
|
||||
@@ -846,6 +868,23 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_player_leaves_its_vehicle_when_its_body_is_removed() {
|
||||
let mut g = guest_at([0.5, -120.0, 0.5]);
|
||||
g.vehicle = Some(7);
|
||||
g.vehicle_good = Some([0.5, -120.0, 0.5]);
|
||||
g.dead = true;
|
||||
// LivingEntity.tickDeath: the body sits in the boat for 19 ticks...
|
||||
for tick in 1..20 {
|
||||
assert!(!g.tick_death(), "tick {tick}");
|
||||
assert_eq!(g.vehicle, Some(7), "tick {tick}");
|
||||
}
|
||||
// ...then remove(KILLED)'s setRemoved stops its riding, so the
|
||||
// respawned player is not put back into the boat.
|
||||
assert!(g.tick_death());
|
||||
assert_eq!((g.vehicle, g.vehicle_good), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_action_must_be_within_reach() {
|
||||
let mut g = guest_at([0.5, 64.0, 0.5]);
|
||||
|
||||
@@ -2552,6 +2552,10 @@ impl Hub {
|
||||
let g = &mut self.guests[i];
|
||||
// PlayerList.respawn: into the level in place (the respawn level).
|
||||
g.dimension = self.config.dimension.clone();
|
||||
// ServerLevel.removePlayerImmediately: the dead player's
|
||||
// Entity.setRemoved (KILLED) stops its riding; the new player rides
|
||||
// nothing.
|
||||
g.stop_riding();
|
||||
// ServerLevel.removePlayerImmediately: every entity paired with the dead player is
|
||||
// removed (ServerEntity.removePairing, one packet each). Its chunks are dropped
|
||||
// without ForgetLevelChunk (PlayerChunkSender.dropChunk skips dead players): the
|
||||
|
||||
@@ -769,8 +769,13 @@ pub struct Extra {
|
||||
/// `LivingEntity.isAutoSpinAttack` (Riptide).
|
||||
pub auto_spin_attack: bool,
|
||||
pub main_hand_left: bool,
|
||||
/// The skin layers a player hides (`PlayerModelPart` bits, set to
|
||||
/// `Avatar.DATA_PLAYER_MODE_CUSTOMISATION` inverted); 0 shows all.
|
||||
pub hidden_model_parts: u8,
|
||||
pub elytra: ElytraAnimation,
|
||||
pub swim: SwimAnimation,
|
||||
/// `ClientAvatarState` of a player: the cloak, bob and walked distance its cape swings with.
|
||||
pub avatar: crate::camera::AvatarBob,
|
||||
/// The arm the current swing moves (`currentSwing.hand().asArm`): `Some(true)`
|
||||
/// for the right arm.
|
||||
pub swing_right_arm: Option<bool>,
|
||||
@@ -965,6 +970,9 @@ pub struct Mob {
|
||||
pub skin: Option<String>,
|
||||
/// `PlayerSkin.model` is `SLIM`: the player model with 3-pixel arms.
|
||||
pub slim: bool,
|
||||
/// `PlayerSkin.cape`: a player's cape texture, once every download of
|
||||
/// its skin is ready.
|
||||
pub cape: Option<String>,
|
||||
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
|
||||
/// `textures` property resolves to `skin` like a player's.
|
||||
pub profile: Option<horizon_protocol::entity_data::Profile>,
|
||||
@@ -1963,6 +1971,7 @@ impl Mob {
|
||||
outbox: Vec::new(),
|
||||
skin: None,
|
||||
slim: false,
|
||||
cape: None,
|
||||
profile: None,
|
||||
attrs: Attrs {
|
||||
speed: kind.speed,
|
||||
@@ -2734,6 +2743,8 @@ impl Mob {
|
||||
/// player from its synched pose and flags: the glide count, the wings
|
||||
/// (`elytraAnimationState`) and the swim amount (`updateSwimAmount`).
|
||||
pub fn tick_avatar_pose(&mut self, in_water: bool) {
|
||||
let (p, v) = (self.previous, self.velocity);
|
||||
let on_ground_alive = self.on_ground && self.alive();
|
||||
let e = &mut self.extra;
|
||||
e.fall_fly_ticks = if e.fall_flying {
|
||||
e.fall_fly_ticks + 1
|
||||
@@ -2745,6 +2756,17 @@ impl Mob {
|
||||
let visually = e.synced_pose == 3 || (!e.fall_flying && e.synced_pose == 1);
|
||||
e.elytra.tick(e.fall_flying, e.crouching, self.velocity);
|
||||
e.swim.tick(visually, in_water);
|
||||
// AbstractClientPlayer.tick's ClientAvatarState.tick (from where the
|
||||
// player was before this tick's move), RemotePlayer.aiStep's
|
||||
// updateBob and rideTick's resetBob.
|
||||
e.avatar.tick([p.x, p.y, p.z]);
|
||||
if e.passenger {
|
||||
e.avatar.reset_bob();
|
||||
} else {
|
||||
let swimming = e.synced_pose == 3;
|
||||
e.avatar
|
||||
.update_bob(on_ground_alive && !swimming, [v.x, v.y, v.z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -952,9 +952,10 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
m.ai.xxa *= 0.98;
|
||||
m.ai.zza *= 0.98;
|
||||
// LivingEntity.isImmobile: no goals or input (a biting, stunned or
|
||||
// roaring ravager). A mannequin is an Avatar, not a Mob: LivingEntity's
|
||||
// roaring ravager, an eating or rearing horse). A mannequin is an Avatar, not a Mob: LivingEntity's
|
||||
// serverAiStep has no goals, so it only falls and is pushed.
|
||||
let immobile = (m.species() == Species::Ravager && super::ravager::immobile(m))
|
||||
|| super::equine::immobile(m)
|
||||
|| m.species() == Species::Mannequin;
|
||||
if m.species() == Species::Piglin && m.alive() {
|
||||
super::piglin::celebrate(m, w);
|
||||
|
||||
@@ -802,6 +802,20 @@ pub fn setup(
|
||||
h.spear_ticks[using_side] = m.extra.using_item as f32 + s.partial;
|
||||
}
|
||||
humanoid(p, s, &h);
|
||||
if player {
|
||||
// PlayerModel.setupAnim: the layers the player hid (PlayerModelPart).
|
||||
let hidden = m.extra.hidden_model_parts;
|
||||
for (bit, part) in [
|
||||
(1, "jacket"),
|
||||
(2, "left_sleeve"),
|
||||
(3, "right_sleeve"),
|
||||
(4, "left_pants"),
|
||||
(5, "right_pants"),
|
||||
(6, "hat"),
|
||||
] {
|
||||
p.visible(part, hidden & (1 << bit) == 0);
|
||||
}
|
||||
}
|
||||
if m.ai.aggressive && !bow {
|
||||
// SkeletonModel melee pose.
|
||||
let sw = s.swing;
|
||||
@@ -893,6 +907,8 @@ pub fn setup(
|
||||
#[cfg(test)]
|
||||
mod flight_pose_tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn blocking_arm_follows_the_head_with_limits_in_both_hands() {
|
||||
@@ -1036,4 +1052,62 @@ mod flight_pose_tests {
|
||||
humanoid(&mut dry, &state, &Hum::new(ArmPose::Empty, ArmPose::Empty));
|
||||
assert_eq!(dry.get("head")[3], 30.0f32.to_radians());
|
||||
}
|
||||
|
||||
/// A player: a humanoid model with a shield in its main hand.
|
||||
fn player() -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.anim = Anim::Humanoid { zombie_arms: false };
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raised_shield_holds_the_arm_across_the_body() {
|
||||
// AvatarRenderer.getArmPose: the used item's use animation decides
|
||||
// the arm's pose, and a shield (an item with `blocks_attacks`) uses
|
||||
// the BLOCK animation, so the arm is held up across the body
|
||||
// (#2289).
|
||||
let state = State {
|
||||
pos: 0.0,
|
||||
speed: 0.0,
|
||||
age: 0.0,
|
||||
y_rot: 0.0,
|
||||
x_rot: 0.0,
|
||||
swing: 0.0,
|
||||
baby: false,
|
||||
partial: 0.0,
|
||||
};
|
||||
let mut p = Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
};
|
||||
let mut m = player();
|
||||
m.extra.main_hand = Some("minecraft:shield".into());
|
||||
m.extra.using_item = 20;
|
||||
m.extra.blocking = true;
|
||||
setup(&m, &state, &mut p, &HashMap::new());
|
||||
// HumanoidModel.setupAnim's BLOCK pose.
|
||||
assert!((p.get("right_arm")[3] + 0.942_477_9).abs() < 1e-6);
|
||||
assert!((p.get("right_arm")[4] + FRAC_PI_6).abs() < 1e-6);
|
||||
// An item that does not block attacks leaves the arm holding it.
|
||||
let mut m = player();
|
||||
m.extra.main_hand = Some("minecraft:stick".into());
|
||||
m.extra.using_item = 20;
|
||||
let mut p = Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (n.to_string(), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
};
|
||||
setup(&m, &state, &mut p, &HashMap::new());
|
||||
assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -70,13 +70,22 @@ pub fn max_temper(s: Species) -> i32 {
|
||||
100
|
||||
}
|
||||
}
|
||||
/// `canPerformRearing`: not donkeys or mules.
|
||||
/// `canPerformRearing`: every `AbstractHorse` but llamas (and camels).
|
||||
fn can_rear(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Horse | Species::SkeletonHorse | Species::ZombieHorse
|
||||
Species::Horse
|
||||
| Species::Donkey
|
||||
| Species::Mule
|
||||
| Species::SkeletonHorse
|
||||
| Species::ZombieHorse
|
||||
)
|
||||
}
|
||||
/// `AbstractHorse.isImmobile`: no goals while it eats or rears. A mount its
|
||||
/// rider steers still takes their input (`travelRidden`).
|
||||
pub fn immobile(m: &Mob) -> bool {
|
||||
can_rear(m.species()) && (m.extra.flag || m.extra.counter > 0) && !controlled(m)
|
||||
}
|
||||
pub fn register(m: &mut Mob) {
|
||||
if super::camel::is_camel(m.species()) {
|
||||
return super::camel::register(m);
|
||||
@@ -283,7 +292,7 @@ impl Goal for RunAroundLikeCrazy {
|
||||
make_mad(m, cx.events);
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.makeMad`: rear with the angry sound.
|
||||
/// `AbstractHorse.makeMad`: rear with the angry sound (`makeSound`).
|
||||
pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
if m.extra.counter > 0 {
|
||||
return;
|
||||
@@ -304,6 +313,57 @@ pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
};
|
||||
m.voice(sound, events);
|
||||
}
|
||||
/// What `Horse`, `AbstractChestedHorse` (donkeys, mules, llamas) and
|
||||
/// `ZombieHorse.mobInteract` do with a held item before
|
||||
/// `AbstractHorse.mobInteract`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HeldItem {
|
||||
/// `isFood`: `fedFood`, whether it eats or not.
|
||||
Feed,
|
||||
/// Untamed: `makeMad` instead of taking the rider.
|
||||
Refused,
|
||||
}
|
||||
/// That branch for a click with `item` (empty for none): no rider, not
|
||||
/// opening a tamed adult's inventory (`sneaking`), not a baby while the
|
||||
/// player holds a golden dandelion (`dandelion`; zombie horses skip that
|
||||
/// check). An untamed mount is made mad here.
|
||||
pub fn held_item(
|
||||
m: &mut Mob,
|
||||
item: &str,
|
||||
sneaking: bool,
|
||||
dandelion: bool,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) -> Option<HeldItem> {
|
||||
let species = m.species();
|
||||
let checks_dandelion = match species {
|
||||
Species::Horse | Species::Donkey | Species::Mule => true,
|
||||
Species::Llama | Species::TraderLlama => true,
|
||||
Species::ZombieHorse => false,
|
||||
_ => return None,
|
||||
};
|
||||
let open_inventory = !m.is_baby() && m.extra.tame && sneaking;
|
||||
if m.rider.is_some()
|
||||
|| open_inventory
|
||||
|| (checks_dandelion && m.is_baby() && dandelion)
|
||||
|| item.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// ZombieHorse.isFood: the zombie_horse_food tag.
|
||||
let food = if species == Species::ZombieHorse {
|
||||
item == "minecraft:red_mushroom"
|
||||
} else {
|
||||
m.kind.food.iter().any(|f| f == item)
|
||||
};
|
||||
if food {
|
||||
return Some(HeldItem::Feed);
|
||||
}
|
||||
if m.extra.tame {
|
||||
return None;
|
||||
}
|
||||
make_mad(m, events);
|
||||
Some(HeldItem::Refused)
|
||||
}
|
||||
/// `RandomStandGoal`.
|
||||
#[derive(Clone, Debug)]
|
||||
struct RandomStand {
|
||||
@@ -318,7 +378,7 @@ impl Goal for RandomStand {
|
||||
self.next += 1;
|
||||
if self.next > 0 && m.random.next_int_bound(1000).unwrap_or(1000) < self.next {
|
||||
self.next = -m.kind.ambient_interval;
|
||||
return m.random.next_int_bound(10).unwrap_or(1) == 0;
|
||||
return !immobile(m) && m.random.next_int_bound(10).unwrap_or(1) == 0;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1229,6 +1229,48 @@ pub fn render_with(
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `CapeLayer.submit` with `PlayerCapeModel`: the player model's body posed
|
||||
/// as the player's, its cape turned by `ModelPart.rotateBy` with the cape's
|
||||
/// flap and leans. `base` is the player's root transform (moved out over a
|
||||
/// chestplate); `uv` the cape texture.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn cape(
|
||||
m: &Mob,
|
||||
alpha: f32,
|
||||
base: glam::Mat4,
|
||||
uv: [f32; 4],
|
||||
light: u32,
|
||||
data: &MobData,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(model) = m.kind.layer("player#cape") else {
|
||||
return;
|
||||
};
|
||||
let s = anim::state(m, alpha);
|
||||
let mut poses = Poses::new(model);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
let a = f64::from(alpha);
|
||||
let at = [
|
||||
m.previous.x + (m.position.x - m.previous.x) * a,
|
||||
m.previous.y + (m.position.y - m.previous.y) * a,
|
||||
m.previous.z + (m.position.z - m.previous.z) * a,
|
||||
];
|
||||
let body = m.previous_body_yaw + wrap(m.body_yaw - m.previous_body_yaw) * alpha;
|
||||
// LivingEntityRenderState.fallFlyingScale.
|
||||
let ticks = m.extra.fall_fly_ticks as f32 + alpha;
|
||||
let gliding = (ticks * ticks / 100.0).clamp(0.0, 1.0);
|
||||
let [flap, lean, lean2] = m.extra.avatar.cape(alpha, at, body, gliding);
|
||||
let pose = poses.get("cape");
|
||||
let by = glam::Quat::from_rotation_y(-PI)
|
||||
* glam::Quat::from_rotation_x((6.0 + lean / 2.0 + flap).to_radians())
|
||||
* glam::Quat::from_rotation_z((lean2 / 2.0).to_radians())
|
||||
* glam::Quat::from_rotation_y((180.0 - lean2 / 2.0).to_radians());
|
||||
let now = glam::Quat::from_euler(glam::EulerRot::ZYX, pose[5], pose[4], pose[3]);
|
||||
let (z, y, x) = (now * by).to_euler(glam::EulerRot::ZYX);
|
||||
(pose[3], pose[4], pose[5]) = (x, y, z);
|
||||
// RenderTypes.entitySolid with OverlayTexture.NO_OVERLAY.
|
||||
draw(model, base, &poses, uv, 0xFFFF_FFFF, light, 0, out);
|
||||
}
|
||||
/// A mob drawn under `origin` instead of at its position (the spinning mob
|
||||
/// of `SpawnerRenderer.submitEntityInSpawner`).
|
||||
pub fn render_posed(
|
||||
|
||||
@@ -241,12 +241,20 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
/// `WitherBoss.customServerAiStep`: charging, then the side heads' targets
|
||||
/// and volleys, block breaking and healing.
|
||||
pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.extra.point = m
|
||||
.target_info(w)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]);
|
||||
if m.extra.counter > 0 {
|
||||
let left = m.extra.counter - 1;
|
||||
if left <= 0 {
|
||||
// `level.explode(this, ...)`: the wither is the blast's source, so
|
||||
// its entity query (`getEntities(source, box)`) leaves it out.
|
||||
let name = match &m.custom_name {
|
||||
Some(name) => crate::text::Text::literal(name.clone()),
|
||||
None => crate::text::Text::translate("entity.minecraft.wither", &[]),
|
||||
};
|
||||
events.push(MobEvent::ExplosionSource {
|
||||
mob: m.id,
|
||||
charged: false,
|
||||
name,
|
||||
});
|
||||
events.push(MobEvent::Explode(
|
||||
[m.position.x, m.eye_y(), m.position.z],
|
||||
7.0,
|
||||
@@ -268,6 +276,11 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
|
||||
}
|
||||
return;
|
||||
}
|
||||
// `setAlternativeTarget(0, ...)` once charged: `aiStep` flies toward this
|
||||
// target only after the charge, never during it.
|
||||
m.extra.point = m
|
||||
.target_info(w)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]);
|
||||
// The side heads pick targets and fire on their own.
|
||||
let age = m.age as i32;
|
||||
for i in 1..3 {
|
||||
|
||||
@@ -3566,6 +3566,40 @@ mod tests {
|
||||
w.player.count_down_server_spin();
|
||||
assert_eq!(w.player.auto_spin_attack_ticks, 0);
|
||||
}
|
||||
/// #3308: a swimmer who boards a boat stops swimming
|
||||
/// (`Entity.updateSwimming` needs `!isPassenger()`) and `updatePlayerPose`
|
||||
/// sits them up, eye at 1.62 instead of the swim's 0.4.
|
||||
#[test]
|
||||
fn a_passenger_stops_swimming_and_stands() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap();
|
||||
player.sprinting = true;
|
||||
player.swimming = true;
|
||||
player.set_pose(crate::pose::Pose::Swimming).unwrap();
|
||||
assert_eq!(player.dimensions.eye_height, 0.4);
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.passenger_pose_tick().unwrap();
|
||||
assert!(!w.player.swimming);
|
||||
assert_eq!(w.player.pose, crate::pose::Pose::Standing);
|
||||
assert_eq!(w.player.dimensions.eye_height, 1.62);
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum Ping {
|
||||
motd: Text,
|
||||
/// Online and max players; None when the status has no `players`.
|
||||
players: Option<(i64, i64)>,
|
||||
/// `ServerData.playerList`: shown over the player count.
|
||||
player_list: Vec<Text>,
|
||||
ms: u128,
|
||||
version: String,
|
||||
},
|
||||
@@ -51,6 +53,26 @@ impl Ping {
|
||||
.with_color(DARK_GRAY)
|
||||
}
|
||||
}
|
||||
/// ServerStatusPinger: each sample name as a literal line (its `§` codes
|
||||
/// styled as the font does), then `multiplayer.status.and_more` for the
|
||||
/// players the sample leaves out.
|
||||
fn player_list(players: &serde_json::Value, online: i64) -> Vec<Text> {
|
||||
let Some(sample) = players["sample"].as_array().filter(|s| !s.is_empty()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut lines: Vec<Text> = sample
|
||||
.iter()
|
||||
.map(|p| Text::literal(p["name"].as_str().unwrap_or_default()).resolve_legacy())
|
||||
.collect();
|
||||
let shown = sample.len() as i64;
|
||||
if shown < online {
|
||||
lines.push(Text::translate(
|
||||
"multiplayer.status.and_more",
|
||||
&[Text::literal((online - shown).to_string())],
|
||||
));
|
||||
}
|
||||
lines
|
||||
}
|
||||
type Pings = Arc<Mutex<HashMap<String, Ping>>>;
|
||||
/// Server icons by address as 64x64 RGBA (FaviconTexture), from servers.dat
|
||||
/// and then from each ping's favicon.
|
||||
@@ -157,6 +179,9 @@ fn ping_all(servers: &[ServerEntry], pings: &Pings, icons: &Icons, file: &Path)
|
||||
p.get("max").and_then(|n| n.as_i64()).unwrap_or(0),
|
||||
)
|
||||
});
|
||||
let player_list = players
|
||||
.map(|(online, _)| player_list(&json["players"], online))
|
||||
.unwrap_or_default();
|
||||
let version = json["version"]["name"].as_str().unwrap_or("?").to_owned();
|
||||
// A new favicon replaces the saved one (an invalid one
|
||||
// clears it) and servers.dat is saved again.
|
||||
@@ -175,6 +200,7 @@ fn ping_all(servers: &[ServerEntry], pings: &Pings, icons: &Icons, file: &Path)
|
||||
Ping::Ok {
|
||||
motd,
|
||||
players,
|
||||
player_list,
|
||||
ms,
|
||||
version,
|
||||
}
|
||||
@@ -367,6 +393,37 @@ fn ping_tooltip(
|
||||
)),
|
||||
})
|
||||
}
|
||||
/// OnlineServerEntry's player list, over the player count drawn left of
|
||||
/// the status icon; `width` measures that count.
|
||||
fn player_list_tooltip(
|
||||
servers: &[ServerEntry],
|
||||
pings: &HashMap<String, Ping>,
|
||||
size: (f32, f32),
|
||||
scroll: f32,
|
||||
cursor: (f32, f32),
|
||||
width: impl Fn(&Text) -> f32,
|
||||
) -> Option<Vec<Text>> {
|
||||
let Row::Server(i) = row_at(servers.len(), 0, cursor, size, scroll)? else {
|
||||
return None;
|
||||
};
|
||||
let Some(Ping::Ok {
|
||||
players,
|
||||
player_list,
|
||||
..
|
||||
}) = pings.get(&servers[i].ip)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let (top, _, left) = list_area(size.0, size.1);
|
||||
let icon_x = left + 2.0 + ROW_WIDTH - 4.0 - 15.0;
|
||||
let y = top + 4.0 - scroll.trunc() + i as f32 * ROW_HEIGHT;
|
||||
let w = width(&Ping::player_count(*players)).ceil();
|
||||
let x = icon_x - w - 5.0;
|
||||
if cursor.0 < x || cursor.0 >= x + w || cursor.1 < y || cursor.1 >= y + 8.0 {
|
||||
return None;
|
||||
}
|
||||
(!player_list.is_empty()).then(|| player_list.clone())
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// Draw after the list and footer so the tooltip is neither clipped nor covered.
|
||||
@@ -389,11 +446,15 @@ impl Screen {
|
||||
bottom,
|
||||
content_height(servers.len(), lan.games().len()),
|
||||
);
|
||||
if let Some(text) =
|
||||
ping_tooltip(servers, &pings.lock().unwrap(), size, scroll.amount, cursor)
|
||||
{
|
||||
crate::ui::tooltip(batch, atlas, &[text], cursor);
|
||||
}
|
||||
let pings = pings.lock().unwrap();
|
||||
let lines = match ping_tooltip(servers, &pings, size, scroll.amount, cursor) {
|
||||
Some(text) => vec![text],
|
||||
None => player_list_tooltip(servers, &pings, size, scroll.amount, cursor, |t| {
|
||||
atlas.font.width_text(t)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
crate::ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
|
||||
pub fn list(saves: &Path) -> Self {
|
||||
@@ -1441,6 +1502,7 @@ mod tests {
|
||||
Ping::Ok {
|
||||
motd: Text::empty(),
|
||||
players: None,
|
||||
player_list: Vec::new(),
|
||||
ms: 42,
|
||||
version: String::new(),
|
||||
},
|
||||
@@ -1477,6 +1539,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_list_styles_sample_names_and_counts_the_rest() {
|
||||
let players = serde_json::json!({
|
||||
"online": 5,
|
||||
"sample": [
|
||||
{"name": "\u{a7}b\u{a7}lBIT\u{a7}rMADE"},
|
||||
{"name": ""},
|
||||
{"name": "\u{a7}8\u{2022}\u{a7}r \u{a7}bLobby"},
|
||||
]
|
||||
});
|
||||
let lines = player_list(&players, 5);
|
||||
assert_eq!(lines.len(), 4);
|
||||
let plain: Vec<String> = lines.iter().take(3).map(Text::plain).collect();
|
||||
assert_eq!(plain, ["BITMADE", "", "\u{2022} Lobby"]);
|
||||
assert_eq!(player_list(&players, 3).len(), 3);
|
||||
assert!(player_list(&serde_json::json!({"online": 2}), 2).is_empty());
|
||||
assert!(player_list(&serde_json::json!({"online": 2, "sample": []}), 2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_list_tooltip_covers_only_the_player_count() {
|
||||
let servers = vec![ServerEntry::new("Test", "server0")];
|
||||
let size = (427.0, 240.0);
|
||||
let mut pings = HashMap::new();
|
||||
let hover = |pings: &HashMap<String, Ping>, cursor| {
|
||||
player_list_tooltip(&servers, pings, size, 0.0, cursor, |_| 20.0)
|
||||
.map(|l| l.iter().map(Text::plain).collect::<Vec<_>>())
|
||||
};
|
||||
assert!(hover(&pings, (324.0, 37.0)).is_none());
|
||||
let ok = |player_list| Ping::Ok {
|
||||
motd: Text::empty(),
|
||||
players: Some((0, 500)),
|
||||
player_list,
|
||||
ms: 1,
|
||||
version: String::new(),
|
||||
};
|
||||
pings.insert("server0".into(), ok(vec![Text::literal("Lobby")]));
|
||||
// The 20-wide count ends 5 pixels left of the bars at (349, 37).
|
||||
for cursor in [(324.0, 37.0), (343.9, 44.9)] {
|
||||
assert_eq!(hover(&pings, cursor), Some(vec!["Lobby".to_owned()]));
|
||||
}
|
||||
for cursor in [
|
||||
(323.9, 37.0),
|
||||
(344.0, 37.0),
|
||||
(324.0, 36.9),
|
||||
(324.0, 45.0),
|
||||
(349.0, 37.0),
|
||||
] {
|
||||
assert!(hover(&pings, cursor).is_none(), "{cursor:?}");
|
||||
}
|
||||
pings.insert("server0".into(), ok(Vec::new()));
|
||||
assert!(hover(&pings, (324.0, 37.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lan_header_follows_the_saved_servers() {
|
||||
let all: Vec<Row> = rows(2, 1).collect();
|
||||
|
||||
@@ -167,4 +167,12 @@ impl MotionWorld<'_, '_, '_> {
|
||||
};
|
||||
self.player.set_pose(pose)
|
||||
}
|
||||
/// The pose part of `Player.tick` for a passenger (`Entity.rideTick` runs
|
||||
/// `tick`, not the travel): `Entity.updateSwimming` stops a swim, since it
|
||||
/// needs `!isPassenger()`, and `updatePlayerPose` follows. A player who
|
||||
/// boards a boat while swimming sits up instead of lying under it (#3308).
|
||||
pub fn passenger_pose_tick(&mut self) -> Result<()> {
|
||||
self.player.swimming = false;
|
||||
self.update_player_pose()
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,100 @@ impl TextDisplayData {
|
||||
}
|
||||
}
|
||||
|
||||
/// `TextDisplay.CachedInfo`: each line and its width, and the widest.
|
||||
pub struct Lines {
|
||||
pub lines: Vec<(Text, f32)>,
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
impl Lines {
|
||||
/// `TextDisplayRenderer.splitLines`: Font.split, no indent on wrapped lines.
|
||||
pub fn split(font: &crate::font::Font, text: &Text, line_width: i32) -> Self {
|
||||
let lines: Vec<_> = font
|
||||
.split_formatted(text, line_width as f32)
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let width = font.width_text(&line).ceil();
|
||||
(line, width)
|
||||
})
|
||||
.collect();
|
||||
let width = lines.iter().map(|l| l.1).fold(0.0, f32::max);
|
||||
Self { lines, width }
|
||||
}
|
||||
/// Ten units a line, less the spacing under the last.
|
||||
pub fn height(&self) -> f32 {
|
||||
self.lines.len() as f32 * 10.0 - 1.0
|
||||
}
|
||||
/// `submitInner`'s pose for the text, after the display's own: half a
|
||||
/// turn about y, the mirrored 0.025 scale and `translate(1 - width / 2,
|
||||
/// -height, 0)`.
|
||||
pub fn pose(&self) -> glam::Mat4 {
|
||||
glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(-0.025))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(
|
||||
1.0 - self.width / 2.0,
|
||||
-self.height(),
|
||||
0.0,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// `TextDisplayRenderer.submitInner` after its `translate(1 - width / 2,
|
||||
/// -height, 0)`: the background and each line, in text units, each quad's
|
||||
/// `depth` its z (the background 0.01 behind, `Font.prepareBackground`).
|
||||
/// `default_background` is `Options.getBackgroundOpacity(0.25)` as a colour.
|
||||
pub fn draw(
|
||||
batch: &mut crate::hud::HudBatch,
|
||||
atlas: &crate::hud::HudAtlas,
|
||||
d: &TextDisplayData,
|
||||
lines: &Lines,
|
||||
default_background: u32,
|
||||
) {
|
||||
use crate::hud::rgba;
|
||||
let (width, height) = (lines.width, lines.height());
|
||||
let bg = if d.flags & 4 != 0 {
|
||||
default_background
|
||||
} else {
|
||||
d.background as u32
|
||||
};
|
||||
if bg != 0 {
|
||||
let start = batch.vertices.len();
|
||||
batch.fill(
|
||||
atlas,
|
||||
// submitTextBackground(-1, -1, width, height): corners, not a size.
|
||||
-1.0,
|
||||
-1.0,
|
||||
width + 1.0,
|
||||
height + 1.0,
|
||||
rgba(
|
||||
(bg >> 16) as u8,
|
||||
(bg >> 8) as u8,
|
||||
bg as u8,
|
||||
(bg >> 24) as u8,
|
||||
),
|
||||
);
|
||||
for v in &mut batch.vertices[start..] {
|
||||
v.depth = -0.01;
|
||||
}
|
||||
}
|
||||
let align = d.align();
|
||||
for (i, (line, line_width)) in lines.lines.iter().enumerate() {
|
||||
let x = match align {
|
||||
Align::Left => 0.0,
|
||||
Align::Right => width - line_width,
|
||||
Align::Center => width / 2.0 - line_width / 2.0,
|
||||
};
|
||||
batch.text_styled_world(
|
||||
atlas,
|
||||
line,
|
||||
x,
|
||||
i as f32 * 10.0,
|
||||
rgba(255, 255, 255, d.opacity as u8),
|
||||
d.flags & 1 != 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -42,11 +42,16 @@ fn translate(key: &str, fallback: &str, args: &[&dyn std::fmt::Display]) -> Stri
|
||||
format_translation(&lang::get_or(key, fallback), &args).plain()
|
||||
}
|
||||
|
||||
enum BackupToast {
|
||||
Created { folder: String, bytes: u64 },
|
||||
enum WorldToast {
|
||||
Created {
|
||||
folder: String,
|
||||
bytes: u64,
|
||||
},
|
||||
Failed(String),
|
||||
/// `SystemToast.onWorldAccessFailure`: the folder of a world that couldn't be locked.
|
||||
AccessFailure(String),
|
||||
}
|
||||
impl BackupToast {
|
||||
impl WorldToast {
|
||||
fn text(&self) -> (String, String) {
|
||||
match self {
|
||||
Self::Created { folder, bytes } => (
|
||||
@@ -61,6 +66,10 @@ impl BackupToast {
|
||||
lang::get_or("selectWorld.edit.backupFailed", "Backup failed"),
|
||||
error.clone(),
|
||||
),
|
||||
Self::AccessFailure(folder) => (
|
||||
lang::get_or("selectWorld.access_failure", "Failed to access world"),
|
||||
folder.clone(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,8 +170,8 @@ pub struct Title {
|
||||
backup_then_open: bool,
|
||||
/// The folder to open next, once that backup has finished.
|
||||
open_request: Option<String>,
|
||||
/// EditWorldScreen's `SystemToast` (`WORLD_BACKUP`) and its slide.
|
||||
toast: Option<(crate::toasts::Slide, BackupToast)>,
|
||||
/// EditWorldScreen's and WorldOpenFlows' `SystemToast` and its slide.
|
||||
toast: Option<(crate::toasts::Slide, WorldToast)>,
|
||||
icons: RefCell<std::collections::HashMap<PathBuf, (std::time::SystemTime, Option<Vec<u8>>)>>,
|
||||
splash: Option<String>,
|
||||
/// The saves listed for Home, re-read every couple of seconds.
|
||||
@@ -538,8 +547,8 @@ impl Title {
|
||||
self.open_request = Some(folder.clone());
|
||||
}
|
||||
let toast = match result {
|
||||
Ok(bytes) => BackupToast::Created { folder, bytes },
|
||||
Err(error) => BackupToast::Failed(error),
|
||||
Ok(bytes) => WorldToast::Created { folder, bytes },
|
||||
Err(error) => WorldToast::Failed(error),
|
||||
};
|
||||
self.toast = Some((crate::toasts::Slide::default(), toast));
|
||||
}
|
||||
@@ -567,9 +576,18 @@ impl Title {
|
||||
pub fn take_open_request(&mut self) -> Option<String> {
|
||||
self.open_request.take()
|
||||
}
|
||||
/// `WorldOpenFlows.openWorldCheckVersionCompatibility`: a world whose file
|
||||
/// fixes are due asks for a backup first; any other opens at once.
|
||||
/// `WorldOpenFlows.openWorld`: a world that can't be locked, as one another
|
||||
/// instance has open, stays closed behind `onWorldAccessFailure`
|
||||
/// (`createWorldAccess`); then `openWorldCheckVersionCompatibility`: a world
|
||||
/// whose file fixes are due asks for a backup first; any other opens at once.
|
||||
fn play(&mut self, world: WorldEntry) -> Action {
|
||||
if crate::save::SessionLock::acquire(&self.saves.join(&world.folder)).is_err() {
|
||||
self.toast = Some((
|
||||
crate::toasts::Slide::default(),
|
||||
WorldToast::AccessFailure(world.folder),
|
||||
));
|
||||
return Action::None;
|
||||
}
|
||||
if crate::save::needs_file_fixes(&self.saves.join(&world.folder)) {
|
||||
self.mode = Mode::BackupBeforeUpgrade { world };
|
||||
return Action::None;
|
||||
|
||||
@@ -358,7 +358,7 @@ impl Title {
|
||||
let (x0, tw) = tabs(w);
|
||||
let right = x0 + tw * TABS.len() as f32;
|
||||
for (x, width) in [(0.0, x0), (right, w - right)] {
|
||||
ui::tiled(
|
||||
ui::tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/header_separator",
|
||||
@@ -366,7 +366,7 @@ impl Title {
|
||||
TAB_BAR - 2.0,
|
||||
width,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
@@ -441,7 +441,7 @@ impl Title {
|
||||
_ => {}
|
||||
}
|
||||
// The footer separator, drawn over the screen.
|
||||
ui::tiled(
|
||||
ui::tiled_sized(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/footer_separator",
|
||||
@@ -449,7 +449,7 @@ impl Title {
|
||||
h - FOOTER - 2.0,
|
||||
w,
|
||||
2.0,
|
||||
32.0,
|
||||
[32.0, 2.0],
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ fn backup_screen_blocks_navigation_and_returns_to_the_selected_world() {
|
||||
matches!(&f.title.mode, Mode::Select { worlds, selected: Some(i) } if worlds[*i].folder == "World")
|
||||
);
|
||||
assert!(
|
||||
matches!(&f.title.toast, Some((_, BackupToast::Created { folder, bytes })) if folder == "World" && *bytes > 0)
|
||||
matches!(&f.title.toast, Some((_, WorldToast::Created { folder, bytes })) if folder == "World" && *bytes > 0)
|
||||
);
|
||||
assert_eq!(
|
||||
Level::read(&f.title.saves.join("World")).unwrap().name,
|
||||
@@ -427,7 +427,7 @@ fn failed_backup_returns_to_the_selected_world_with_an_error_toast() {
|
||||
assert!(
|
||||
matches!(&f.title.mode, Mode::Select { worlds, selected: Some(i) } if worlds[*i].folder == "World")
|
||||
);
|
||||
let Some((_, BackupToast::Failed(error))) = &f.title.toast else {
|
||||
let Some((_, WorldToast::Failed(error))) = &f.title.toast else {
|
||||
panic!("backup failures should produce an error toast");
|
||||
};
|
||||
assert!(!error.is_empty());
|
||||
@@ -456,7 +456,7 @@ fn stopped_backup_worker_returns_to_the_list_with_an_error_toast() {
|
||||
matches!(&f.title.mode, Mode::Select { worlds, selected: Some(i) } if worlds[*i].folder == "World")
|
||||
);
|
||||
assert!(
|
||||
matches!(&f.title.toast, Some((_, BackupToast::Failed(error))) if error == "Backup worker stopped")
|
||||
matches!(&f.title.toast, Some((_, WorldToast::Failed(error))) if error == "Backup worker stopped")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -602,6 +602,34 @@ fn classic_world_selection_is_silent_until_the_entry_launches() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_open_elsewhere_stays_closed_behind_a_toast() {
|
||||
let mut f = Fixture::new();
|
||||
f.world("World", "World");
|
||||
f.title.enter_singleplayer();
|
||||
let open = crate::save::SessionLock::acquire(&f.title.saves.join("World")).unwrap();
|
||||
let (icon_x, icon_y) = (240.0 - 135.0 + 2.0, LIST_TOP + 4.0);
|
||||
assert_eq!(
|
||||
f.title
|
||||
.click(icon_x + 16.0, icon_y + 16.0, (480.0, 262.0))
|
||||
.0,
|
||||
Action::None
|
||||
);
|
||||
assert!(
|
||||
matches!(&f.title.toast, Some((_, WorldToast::AccessFailure(folder))) if folder == "World")
|
||||
);
|
||||
assert!(matches!(f.title.mode, Mode::Select { .. }));
|
||||
drop(open);
|
||||
assert_eq!(
|
||||
f.title
|
||||
.click(icon_x + 16.0, icon_y + 16.0, (480.0, 262.0))
|
||||
.0,
|
||||
Action::Play {
|
||||
folder: "World".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_world_icon_joins_it_at_once() {
|
||||
let mut f = Fixture::new();
|
||||
|
||||
@@ -20,7 +20,9 @@ use std::{
|
||||
thread,
|
||||
};
|
||||
|
||||
mod islands;
|
||||
mod vegetation;
|
||||
use islands::EndIslands;
|
||||
use vegetation::Vegetation;
|
||||
const CACHE: usize = 1024;
|
||||
const REQUESTS: usize = 64;
|
||||
@@ -299,6 +301,7 @@ pub(super) struct VanillaLodSampler {
|
||||
worker: GeneratorWorker,
|
||||
builder: Option<Builder>,
|
||||
vegetation: Arc<Vegetation>,
|
||||
islands: EndIslands,
|
||||
cache: FxHashMap<(i32, i32), Arc<[Column; 256]>>,
|
||||
order: VecDeque<(i32, i32)>,
|
||||
details: Vec<LodDetail>,
|
||||
@@ -312,11 +315,13 @@ impl VanillaLodSampler {
|
||||
&generator.biome_map,
|
||||
generator.seed,
|
||||
));
|
||||
let islands = EndIslands::new(generator.clone());
|
||||
Self {
|
||||
generator,
|
||||
worker,
|
||||
builder,
|
||||
vegetation,
|
||||
islands,
|
||||
cache: FxHashMap::default(),
|
||||
order: VecDeque::new(),
|
||||
details: Vec::new(),
|
||||
@@ -330,6 +335,7 @@ impl LodSampler for VanillaLodSampler {
|
||||
worker: self.generator.generator.worker(),
|
||||
builder: None,
|
||||
vegetation: self.vegetation.clone(),
|
||||
islands: self.islands.fork(),
|
||||
cache: FxHashMap::default(),
|
||||
order: VecDeque::new(),
|
||||
details: Vec::new(),
|
||||
@@ -428,7 +434,7 @@ impl LodSampler for VanillaLodSampler {
|
||||
}
|
||||
impl VanillaLodSampler {
|
||||
fn preview_column(
|
||||
&self,
|
||||
&mut self,
|
||||
x: i32,
|
||||
z: i32,
|
||||
width: i32,
|
||||
@@ -468,6 +474,7 @@ impl VanillaLodSampler {
|
||||
240
|
||||
};
|
||||
self.vegetation.apply(&mut column, x, z, width, light);
|
||||
self.islands.apply(&mut column, x, z, width, light);
|
||||
column
|
||||
}
|
||||
}
|
||||
@@ -771,6 +778,166 @@ mod tests {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Small End islands are decoration over the void, so the direct sampler
|
||||
/// has to estimate them from `end_island_decorated`.
|
||||
#[test]
|
||||
#[ignore = "requires prepared worldgen data in HORIZON_TEST_RESOURCES"]
|
||||
fn end_small_islands_are_estimated_over_the_void() -> crate::Result<()> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("resources"),
|
||||
);
|
||||
let assets = crate::assets::Assets::load(&root)?;
|
||||
let biomes = crate::biome::Biomes::load(&root)?;
|
||||
let terrain = Arc::new(VanillaTerrain::for_dimension(
|
||||
&root.join("worldgen"),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
1234,
|
||||
"minecraft:the_end",
|
||||
)?);
|
||||
let mut sampler = VanillaLodSampler::new(terrain.clone());
|
||||
let mut search = terrain.generator.worker();
|
||||
let mut center = None;
|
||||
'search: for x in (1024..=8192).step_by(512) {
|
||||
for z in (-8192..=8192).step_by(512) {
|
||||
let biome = search.noise_biomes(&[[x >> 2, 16, z >> 2]]);
|
||||
if biome.first().and_then(|name| name.as_deref())
|
||||
!= Some("minecraft:small_end_islands")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if search.surface_sample(x, z).is_some() {
|
||||
continue;
|
||||
}
|
||||
center = Some((x, z));
|
||||
break 'search;
|
||||
}
|
||||
}
|
||||
let (cx, cz) = center.expect("a small end islands region");
|
||||
let end_stone = assets
|
||||
.registry
|
||||
.default_state("minecraft:end_stone")
|
||||
.unwrap();
|
||||
// The estimator itself, a wide void at one block per cell.
|
||||
let mut islands = 0;
|
||||
for z in cz - 128..cz + 128 {
|
||||
for x in cx - 128..cx + 128 {
|
||||
let mut column = Column::default();
|
||||
sampler.islands.apply(&mut column, x, z, 1, 240);
|
||||
let Some(surface) = column.surface else {
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
(56..=71).contains(&surface.height),
|
||||
"island top at {}",
|
||||
surface.height
|
||||
);
|
||||
assert_eq!(surface.state, end_stone);
|
||||
assert!(column.spans.iter().any(|span| {
|
||||
span.state == end_stone && i32::from(span.max) == surface.height
|
||||
}));
|
||||
assert!(
|
||||
column.spans.iter().all(|span| i32::from(span.min) >= 45),
|
||||
"an estimated island floats above the void"
|
||||
);
|
||||
islands += 1;
|
||||
}
|
||||
}
|
||||
assert!(islands > 0, "no estimated small islands in a 256² void");
|
||||
// And through the whole direct-sampling path with the real biome map.
|
||||
let sampled = sampler.grid(cx - 32, cz - 32, 1, 64);
|
||||
assert!(
|
||||
sampled.iter().any(|column| column
|
||||
.surface
|
||||
.is_some_and(|surface| surface.state == end_stone)),
|
||||
"direct sampling shows an island in the real small end islands biome"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
/// The refined path must keep the real feature's end stone in its columns.
|
||||
#[test]
|
||||
#[ignore = "requires prepared worldgen data in HORIZON_TEST_RESOURCES"]
|
||||
fn end_small_islands_from_decoration_survive_far_columns() -> crate::Result<()> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("resources"),
|
||||
);
|
||||
let assets = crate::assets::Assets::load(&root)?;
|
||||
let biomes = crate::biome::Biomes::load(&root)?;
|
||||
let terrain = Arc::new(VanillaTerrain::for_dimension(
|
||||
&root.join("worldgen"),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
1234,
|
||||
"minecraft:the_end",
|
||||
)?);
|
||||
let sampler = VanillaLodSampler::new(terrain.clone());
|
||||
let mut worker = terrain.generator.worker();
|
||||
let stop = AtomicBool::new(false);
|
||||
let mut centers = Vec::new();
|
||||
'search: for cx in (64..=512).step_by(4) {
|
||||
for cz in (-512..=512).step_by(4) {
|
||||
let (x, z) = (cx * 16 + 8, cz * 16 + 8);
|
||||
let biome = worker.noise_biomes(&[[x >> 2, 16, z >> 2]]);
|
||||
if biome.first().and_then(|name| name.as_deref())
|
||||
!= Some("minecraft:small_end_islands")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if worker.surface_sample(x, z).is_some() {
|
||||
continue;
|
||||
}
|
||||
centers.push((cx, cz));
|
||||
if centers.len() >= 200 {
|
||||
break 'search;
|
||||
}
|
||||
}
|
||||
}
|
||||
for &(cx, cz) in ¢ers {
|
||||
// The replay is cheap; only decorate chunks it actually places in.
|
||||
let origins = sampler.islands.origins(cx, cz);
|
||||
if origins.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(chunk) = decorated_region(&mut worker, (cx, cz), &stop)? else {
|
||||
continue;
|
||||
};
|
||||
for [x, y, z] in &origins {
|
||||
assert!((55..=70).contains(y), "island origin at y {y} in {cx} {cz}");
|
||||
assert_eq!(
|
||||
chunk.block_name(*x, *y, *z),
|
||||
"minecraft:end_stone",
|
||||
"exact origin {x} {y} {z} is the real island's top centre"
|
||||
);
|
||||
}
|
||||
let min = chunk.terrain.min_y();
|
||||
let max = min + chunk.terrain.height();
|
||||
let mut raw = 0;
|
||||
for y in min..max {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
raw += usize::from(chunk.block_name(x, y, z) == "minecraft:end_stone");
|
||||
}
|
||||
}
|
||||
}
|
||||
let kept: usize = columns(&chunk, &terrain.biome_map, "minecraft:the_end")
|
||||
.iter()
|
||||
.flat_map(|column| &column.spans)
|
||||
.filter(|span| {
|
||||
assets.registry.state(span.state).unwrap().name == "minecraft:end_stone"
|
||||
})
|
||||
.map(|span| (span.max - span.min) as usize)
|
||||
.sum();
|
||||
assert_eq!(
|
||||
raw, kept,
|
||||
"far columns keep the real small island in {cx} {cz}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
panic!(
|
||||
"no exact small end island among {} candidates",
|
||||
centers.len()
|
||||
);
|
||||
}
|
||||
/// Vanilla decoration places chorus plants in the End; the far columns must
|
||||
/// keep them even though they are not motion-blocking blocks.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Small End islands at far distances.
|
||||
//!
|
||||
//! The End's small islands are decoration (`minecraft:end_island_decorated` in
|
||||
//! `minecraft:small_end_islands`), not terrain density. The generator replays
|
||||
//! vanilla's decoration seed and feature on a no-op world, so the origins and
|
||||
//! heights are exact without generating chunks; the visible shape is the cone
|
||||
//! the direct sampler always drew. Refined, visited and saved chunks still win.
|
||||
use super::vegetation::hash;
|
||||
use crate::{
|
||||
fast_hash::FxHashMap,
|
||||
lod::{Column, Span, Surface},
|
||||
vanillagen::VanillaTerrain,
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
|
||||
/// A cell is drawn as a whole column; past this width a 4–6 block island would
|
||||
/// be a slab. At 8 blocks a whole island is about one cell, which is enough at
|
||||
/// the distance where direct sampling takes over.
|
||||
const END_ISLAND_CELL: i32 = 8;
|
||||
/// `EndIslandFeature` starts at radius 4..=6.
|
||||
const MAX_RADIUS: i32 = 6;
|
||||
const CACHE: usize = 4096;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Island {
|
||||
x: i32,
|
||||
z: i32,
|
||||
y: i32,
|
||||
/// Top radius, 4..=6 blocks.
|
||||
radius: i32,
|
||||
/// Per-layer shrink in half blocks: 1 = 0.5, 3 = 1.5.
|
||||
shrink: i32,
|
||||
}
|
||||
|
||||
pub(super) struct EndIslands {
|
||||
generator: Arc<VanillaTerrain>,
|
||||
end_stone: Option<BlockStateId>,
|
||||
cache: FxHashMap<(i32, i32), Arc<[Island]>>,
|
||||
order: VecDeque<(i32, i32)>,
|
||||
}
|
||||
impl EndIslands {
|
||||
pub(super) fn new(generator: Arc<VanillaTerrain>) -> Self {
|
||||
let end_stone = generator
|
||||
.generator
|
||||
.data()
|
||||
.registry()
|
||||
.default_state("minecraft:end_stone");
|
||||
Self {
|
||||
generator,
|
||||
end_stone,
|
||||
cache: FxHashMap::default(),
|
||||
order: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
/// Independent sampling state: the generator is shared, the cache is not.
|
||||
pub(super) fn fork(&self) -> Self {
|
||||
Self {
|
||||
generator: self.generator.clone(),
|
||||
end_stone: self.end_stone,
|
||||
cache: FxHashMap::default(),
|
||||
order: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
/// Paint the small islands of the chunks around a direct-sampled void cell.
|
||||
pub(super) fn apply(&mut self, column: &mut Column, x: i32, z: i32, width: i32, light: u16) {
|
||||
let Some(state) = self.end_stone else {
|
||||
return;
|
||||
};
|
||||
if width > END_ISLAND_CELL || width <= 0 || column.surface.is_some() {
|
||||
return;
|
||||
}
|
||||
let (last_x, last_z) = (x + width - 1, z + width - 1);
|
||||
let mut spans = Vec::new();
|
||||
for cz in (z - MAX_RADIUS).div_euclid(16)..=(last_z + MAX_RADIUS).div_euclid(16) {
|
||||
for cx in (x - MAX_RADIUS).div_euclid(16)..=(last_x + MAX_RADIUS).div_euclid(16) {
|
||||
let islands = self.layout(cx, cz);
|
||||
for island in islands.iter() {
|
||||
let nx = island.x.clamp(x, last_x);
|
||||
let nz = island.z.clamp(z, last_z);
|
||||
let d2 = (nx - island.x) * (nx - island.x) + (nz - island.z) * (nz - island.z);
|
||||
let Some(bottom) = island_bottom(island, d2) else {
|
||||
continue;
|
||||
};
|
||||
spans.push((bottom, island.y + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if spans.is_empty() {
|
||||
return;
|
||||
}
|
||||
spans.sort_unstable_by_key(|span| span.0);
|
||||
let mut merged: Vec<(i32, i32)> = Vec::with_capacity(spans.len());
|
||||
for (min, max) in spans {
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& min <= last.1
|
||||
{
|
||||
last.1 = last.1.max(max);
|
||||
} else {
|
||||
merged.push((min, max));
|
||||
}
|
||||
}
|
||||
for &(min, max) in &merged {
|
||||
column.push(Span {
|
||||
min: min as i16,
|
||||
max: max as i16,
|
||||
state,
|
||||
biome: 0,
|
||||
light,
|
||||
});
|
||||
}
|
||||
let top = merged.last().unwrap().1;
|
||||
column.surface = Some(Surface {
|
||||
height: top,
|
||||
state,
|
||||
water: None,
|
||||
biome: 0,
|
||||
});
|
||||
}
|
||||
fn layout(&mut self, cx: i32, cz: i32) -> Arc<[Island]> {
|
||||
let key = (cx, cz);
|
||||
if let Some(islands) = self.cache.get(&key) {
|
||||
return islands.clone();
|
||||
}
|
||||
let origins = self
|
||||
.generator
|
||||
.generator
|
||||
.end_island_origins(ChunkPos::new(cx, cz));
|
||||
let islands: Arc<[Island]> = origins
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| {
|
||||
let n = hash(
|
||||
self.generator.seed as u64
|
||||
^ (x as u64).rotate_left(17)
|
||||
^ (z as u64).rotate_left(33)
|
||||
^ (y as u64),
|
||||
x,
|
||||
z,
|
||||
);
|
||||
Island {
|
||||
x,
|
||||
z,
|
||||
y,
|
||||
radius: 4 + (n % 3) as i32,
|
||||
shrink: 1 + 2 * ((n >> 8) & 1) as i32,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.cache.insert(key, islands.clone());
|
||||
self.order.push_back(key);
|
||||
while self.cache.len() > CACHE {
|
||||
if let Some(old) = self.order.pop_front() {
|
||||
self.cache.remove(&old);
|
||||
}
|
||||
}
|
||||
islands
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(super) fn origins(&self, cx: i32, cz: i32) -> Vec<[i32; 3]> {
|
||||
self.generator
|
||||
.generator
|
||||
.end_island_origins(ChunkPos::new(cx, cz))
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest layer of the island's cone that reaches a column at squared
|
||||
/// distance `d2`. The top layer is always `island.y` when this returns `Some`.
|
||||
fn island_bottom(island: &Island, d2: i32) -> Option<i32> {
|
||||
let mut half = island.radius * 2;
|
||||
let mut y = island.y;
|
||||
let mut bottom = None;
|
||||
while half > 1 {
|
||||
if d2 * 4 <= half * half {
|
||||
bottom = Some(y);
|
||||
}
|
||||
half -= island.shrink;
|
||||
y -= 1;
|
||||
}
|
||||
bottom
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn island_columns_are_cones_with_a_flat_top() {
|
||||
let island = Island {
|
||||
x: 0,
|
||||
z: 0,
|
||||
y: 60,
|
||||
radius: 4,
|
||||
shrink: 1,
|
||||
};
|
||||
assert_eq!(island_bottom(&island, 0), Some(54));
|
||||
assert_eq!(island_bottom(&island, 3), Some(56));
|
||||
assert_eq!(island_bottom(&island, 16), Some(60));
|
||||
assert_eq!(island_bottom(&island, 17), None);
|
||||
let steep = Island {
|
||||
shrink: 3,
|
||||
..island
|
||||
};
|
||||
assert_eq!(island_bottom(&steep, 0), Some(58));
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ fn gather(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn hash(seed: u64, x: i32, z: i32) -> u64 {
|
||||
pub(super) fn hash(seed: u64, x: i32, z: i32) -> u64 {
|
||||
let mut n = seed
|
||||
^ (x as u64).wrapping_mul(0x9e3779b97f4a7c15)
|
||||
^ (z as u64).wrapping_mul(0xd1b54a32d192ed03);
|
||||
|
||||
@@ -709,3 +709,97 @@ fn guests_see_each_other_and_the_world_entities() {
|
||||
first.join().unwrap().unwrap();
|
||||
drop(second);
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleContainerClick`: a spectator looks
|
||||
/// into a chest but its clicks change nothing, and `sendAllDataToRemote`
|
||||
/// takes back what its client predicted.
|
||||
#[test]
|
||||
fn a_spectators_container_click_is_taken_back() {
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, lan::menu::Slots};
|
||||
struct NoRules;
|
||||
impl horizon_client::lan::menu::Rules for NoRules {
|
||||
fn items(&self) -> &horizon_client::items::Items {
|
||||
unreachable!("a spectator's click never reaches the menu")
|
||||
}
|
||||
fn max_stack(&self, _: &str) -> u32 {
|
||||
64
|
||||
}
|
||||
fn armor_index(&self, _: &str) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let Some(resources) = resources() else {
|
||||
return;
|
||||
};
|
||||
let mut s = server(&resources);
|
||||
let port = s.hub.lan.port;
|
||||
let mut bot = run(&mut s, move || connect(port, "Watcher", true));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while s.hub.guests.is_empty() {
|
||||
s.tick(&[]);
|
||||
assert!(Instant::now() < deadline, "guest did not join");
|
||||
thread::yield_now();
|
||||
}
|
||||
let conn = s.hub.guests[0].conn;
|
||||
s.hub.set_game_mode(conn, 3);
|
||||
let at = s.hub.guests[0].position;
|
||||
let key = (
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32 + 2,
|
||||
);
|
||||
s.world.set(key.0, key.1, key.2, BlockStateId(2)).unwrap();
|
||||
let mut items = vec![None; 27];
|
||||
items[0] = Some(ItemStack::new("minecraft:diamond", 5));
|
||||
s.world
|
||||
.block_entities
|
||||
.insert(key, BlockEntity::Chest(items.clone()));
|
||||
s.hub.open_container(
|
||||
conn,
|
||||
"minecraft:generic_9x3",
|
||||
horizon_protocol::text::translatable("container.chest", &[]),
|
||||
key,
|
||||
Slots::Block(key),
|
||||
items.clone(),
|
||||
);
|
||||
let window = s.hub.guests[0].menu.as_ref().unwrap().window;
|
||||
// The menu's own contents, as the client first sees them.
|
||||
bot.c
|
||||
.reader
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let content = move |bot: &mut Bot| -> Result<(), String> {
|
||||
loop {
|
||||
if let C::ContainerSetContent(p) = bot.recv_play()?
|
||||
&& p.container == window
|
||||
{
|
||||
assert!(p.items[0].is_some(), "the diamonds are still in slot 0");
|
||||
assert!(p.carried.is_none(), "nothing is carried");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut bot = run(&mut s, move || content(&mut bot).map(|()| bot));
|
||||
// The client predicted picking up the diamonds.
|
||||
let click = play::ContainerClick {
|
||||
container: window,
|
||||
state: 1,
|
||||
slot: 0,
|
||||
button: 0,
|
||||
input: 0,
|
||||
changed: vec![(0, None)],
|
||||
carried: None,
|
||||
};
|
||||
let drops = s.hub.container_click(conn, &click, &mut s.world, &NoRules);
|
||||
assert!(drops.is_empty());
|
||||
assert_eq!(
|
||||
s.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.map(|b| b.slots()[0].clone()),
|
||||
Some(items[0].clone()),
|
||||
"the chest keeps its diamonds"
|
||||
);
|
||||
assert!(s.hub.guests[0].carried.is_none());
|
||||
run(&mut s, move || content(&mut bot));
|
||||
}
|
||||
@@ -314,7 +314,10 @@ interface render {
|
||||
record sprite-shape {
|
||||
/// One of the game's GUI sprites, as `gui/sprites/` names them
|
||||
/// (`hud/heart/full`, `widget/button`), the resource packs' versions
|
||||
/// included. An unknown one draws nothing.
|
||||
/// included, drawn over `rect` as vanilla draws it: nine-sliced when
|
||||
/// its `gui.scaling` says so (`tooltip/frame`, `widget/button`: the
|
||||
/// borders keep their size), stretched otherwise. An unknown one
|
||||
/// draws nothing.
|
||||
sprite: string,
|
||||
rect: rect,
|
||||
/// A tint, 0xAARRGGBB: 0xFFFFFFFF draws it as it is.
|
||||
|
||||
@@ -47,6 +47,7 @@ pub mod component {
|
||||
pub const FIREWORK_EXPLOSION: i32 = 70;
|
||||
pub const FIREWORKS: i32 = 71;
|
||||
pub const BLOCK_ENTITY_DATA: i32 = 62;
|
||||
pub const OMINOUS_BOTTLE_AMPLIFIER: i32 = 65;
|
||||
pub const CONTAINER: i32 = 77;
|
||||
}
|
||||
|
||||
@@ -183,8 +184,14 @@ impl Component {
|
||||
pub(crate) fn read(kind: i32, r: &mut Reader) -> Result<Self> {
|
||||
use component::*;
|
||||
Ok(match kind {
|
||||
MAX_STACK_SIZE | MAX_DAMAGE | DAMAGE | RARITY | REPAIR_COST | MAP_ID
|
||||
| MAP_POST_PROCESSING => Self::Int(r.varint()?),
|
||||
MAX_STACK_SIZE
|
||||
| MAX_DAMAGE
|
||||
| DAMAGE
|
||||
| RARITY
|
||||
| REPAIR_COST
|
||||
| MAP_ID
|
||||
| MAP_POST_PROCESSING
|
||||
| OMINOUS_BOTTLE_AMPLIFIER => Self::Int(r.varint()?),
|
||||
WRITABLE_BOOK_CONTENT => {
|
||||
let n = r.count(100)?;
|
||||
Self::WritableBook(
|
||||
@@ -862,6 +869,15 @@ mod tests {
|
||||
book.added
|
||||
.push((component::MAP_POST_PROCESSING, Component::Int(1)));
|
||||
round::<Option<Stack>, Slot>(Some(book));
|
||||
// OminousBottleAmplifier.STREAM_CODEC: a VarInt.
|
||||
let mut bottle = Stack::new(1200, 1);
|
||||
bottle
|
||||
.added
|
||||
.push((component::OMINOUS_BOTTLE_AMPLIFIER, Component::Int(3)));
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&Some(bottle.clone()), &mut w);
|
||||
assert_eq!(w.data, [1, 0xb0, 0x09, 1, 0, 65, 3]);
|
||||
round::<Option<Stack>, Slot>(Some(bottle));
|
||||
let mut quill = Stack::new(1099, 1);
|
||||
quill.added.push((
|
||||
component::WRITABLE_BOOK_CONTENT,
|
||||
|
||||
@@ -433,6 +433,15 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
self.level_event(2001, head_pos, head.0 as i32);
|
||||
}
|
||||
}
|
||||
// PistonHeadBlock.playerWillDestroy: in creative, destroy the base piston
|
||||
// without drops so removing the head does not drop a piston item.
|
||||
if creative && data.is(Class::PistonHead) {
|
||||
let base_pos = rel(pos, b.facing(state).opposite());
|
||||
let base = self.state(base_pos);
|
||||
if self.piston_head_fitting_base(state, base) {
|
||||
self.destroy_block(base_pos, false, super::flags::UPDATE_LIMIT);
|
||||
}
|
||||
}
|
||||
// TntBlock.playerWillDestroy: unstable TNT primes unless instabuild.
|
||||
if !creative && data.is(Class::Tnt) && b.table.bool(state, p.unstable) {
|
||||
self.prime_tnt(pos);
|
||||
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user