mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b792957f7 | ||
|
|
2c6b60f3fb | ||
|
|
8f827db52d | ||
|
|
0e0b0c947f | ||
|
|
e065f2af6f | ||
|
|
8ccf20d909 | ||
|
|
7ace1e9e8f | ||
|
|
ebf2132885 | ||
|
|
4fe648b674 | ||
|
|
747c22a5c2 | ||
|
|
cab57a158f | ||
|
|
9cbf93a193 | ||
|
|
b1afe112a4 | ||
|
|
b107b8539d | ||
|
|
df518c1683 | ||
|
|
2aa41f867e | ||
|
|
8741f4d590 | ||
|
|
d880b23fec | ||
|
|
c97be88ae5 | ||
|
|
600b2a1dda | ||
|
|
03b52db24e | ||
|
|
27f9120510 | ||
|
|
0a699d34f5 | ||
|
|
96afc48ce2 | ||
|
|
804a288214 | ||
|
|
1851dc87a7 | ||
|
|
ba797f60a1 | ||
|
|
95f9ad56b2 | ||
|
|
528d65ab12 | ||
|
|
f8eb2f086e | ||
|
|
26a2aa2fdc | ||
|
|
006006f1d5 | ||
|
|
453ee22376 | ||
|
|
db4ea8a200 | ||
|
|
d48e56778e | ||
|
|
17e4d547c7 | ||
|
|
b242a5ac15 | ||
|
|
8975e2370b | ||
|
|
9a7f75b75d | ||
|
|
a4839d322b | ||
|
|
c5a2259d92 | ||
|
|
7ec3fd2b4c | ||
|
|
be73f4ac6a | ||
|
|
8027cb4176 | ||
|
|
91400aac5b | ||
|
|
26894ab3fd | ||
|
|
ae17e26009 | ||
|
|
ae3b844d74 | ||
|
|
03c8594494 | ||
|
|
9540734e29 | ||
|
|
601df6c22d | ||
|
|
3dd17ad7d7 | ||
|
|
394ce4928c | ||
|
|
0e4965845a | ||
|
|
8fa967ac07 | ||
|
|
aec52c85e9 | ||
|
|
a0a170064e | ||
|
|
caae026416 | ||
|
|
92ed365a19 | ||
|
|
c65338b3cb | ||
|
|
590ca775c2 | ||
|
|
38bea19137 | ||
|
|
282556870f | ||
|
|
a03940e0a8 | ||
|
|
4f70efcfa9 | ||
|
|
a83d6f33be | ||
|
|
9c26db87e6 | ||
|
|
f49d5d91e6 | ||
|
|
ec7a2e22a7 | ||
|
|
3caf8f7014 | ||
|
|
8d6d1f26a4 | ||
|
|
b78daab11c | ||
|
|
da54f988d4 | ||
|
|
455817204f | ||
|
|
aadb8b6727 | ||
|
|
7b94de404e | ||
|
|
9e76f8c4f0 | ||
|
|
ae63b23dd6 | ||
|
|
d983ef89b4 | ||
|
|
145c4c877b | ||
|
|
a27579dd72 | ||
|
|
ddbf2ce211 | ||
|
|
e2e591858e | ||
|
|
51425c9c22 | ||
|
|
02f147f137 | ||
|
|
c2d137ed06 | ||
|
|
81e4272126 | ||
|
|
7e047c409c | ||
|
|
11eb74b0b6 | ||
|
|
fcbadbaa30 | ||
|
|
db5c10ba20 | ||
|
|
f4b4f944e3 | ||
|
|
a9453b7c93 | ||
|
|
990786901a | ||
|
|
1f51e3d18c | ||
|
|
49b167ce22 | ||
|
|
1e6884c591 | ||
|
|
76e2c84103 | ||
|
|
0a2de159fd | ||
|
|
78bfe74465 | ||
|
|
d25d0f025b | ||
|
|
2918f31811 | ||
|
|
52a0a5a89c | ||
|
|
8c6ec16c99 | ||
|
|
7a2de4e328 | ||
|
|
6b60056637 | ||
|
|
728c6a62b3 | ||
|
|
9b863d6093 | ||
|
|
e7f41e7d13 | ||
|
|
290e2e1ac7 | ||
|
|
941f148957 | ||
|
|
ad7203fc8a | ||
|
|
fa8291fc97 | ||
|
|
593b99a68b | ||
|
|
c675329208 | ||
|
|
ad50996f7c | ||
|
|
b7b36836af | ||
|
|
9a0fcda3a9 | ||
|
|
df3ac51447 | ||
|
|
c776d6586e | ||
|
|
1bbe64265c | ||
|
|
3e79b22c43 | ||
|
|
d1c1af598b | ||
|
|
87e3df5331 | ||
|
|
955b6c2134 | ||
|
|
f4b04218f1 | ||
|
|
4a7173918b | ||
|
|
b59d9ada27 | ||
|
|
fdba6b4153 | ||
|
|
9dc72bf3ac | ||
|
|
1e9644cc15 | ||
|
|
77def1f2dd | ||
|
|
c68e55438f | ||
|
|
bf2961834b | ||
|
|
3d401e55f4 | ||
|
|
5febf0d957 | ||
|
|
d41aa95dbd | ||
|
|
51911080f0 | ||
|
|
060a37dc85 | ||
|
|
3a35e147e5 | ||
|
|
260dd423c9 |
No files matched your search
@@ -2,7 +2,6 @@
|
||||
target/
|
||||
.local/
|
||||
**/node_modules/
|
||||
website/
|
||||
benchmarks/
|
||||
packs/
|
||||
mods/
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
# where Git for Windows' core.autocrlf would check them out with CRLF.
|
||||
docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
website/src/tokens.css text eol=lf
|
||||
crates/horizon-theme/tokens.css text eol=lf
|
||||
docs/api/*-reference.md text eol=lf
|
||||
@@ -48,6 +48,9 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
|
||||
- name: The harnesses the merges since then can affect
|
||||
id: harnesses
|
||||
# A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD.
|
||||
continue-on-error: true
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
@@ -55,8 +58,8 @@ jobs:
|
||||
echo "Merges since ${BASE_SHA}:"
|
||||
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
|
||||
- name: The merge that broke them
|
||||
if: failure()
|
||||
- name: A flake, or the merge that broke them
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PARITY_HOSTS: ''
|
||||
@@ -65,7 +68,7 @@ jobs:
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-main-logs
|
||||
|
||||
@@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
|
||||
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
|
||||
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
|
||||
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
|
||||
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
|
||||
|
||||
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
|
||||
release them. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
|
||||
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
|
||||
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
|
||||
|
||||
Generated
+9
@@ -1368,6 +1368,7 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
@@ -1426,6 +1427,14 @@ dependencies = [
|
||||
"spake2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-hdr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
+68
-54
@@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
|
||||
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
|
||||
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -160,6 +160,66 @@ Translatable components resolved through the selected language for items, contai
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
|
||||
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
|
||||
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
|
||||
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
|
||||
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Shader packs, written again from scratch
|
||||
@@ -250,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck,
|
||||
|
||||
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
|
||||
|
||||
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
|
||||
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -540,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -876,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
|
||||
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -902,56 +966,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
|
||||
</details>
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **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/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
|
||||
|
||||
@@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
|
||||
|
||||
`crates/horizon-theme/icons/pixel/*.svg` are icons from
|
||||
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
|
||||
the same grid.
|
||||
@@ -12,6 +12,7 @@ horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use horizon_client::{
|
||||
Result,
|
||||
cushion::{self, World},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
struct Cell {
|
||||
outline: VoxelShape,
|
||||
collision: VoxelShape,
|
||||
suffocating: bool,
|
||||
}
|
||||
struct Fixture(HashMap<[i32; 3], Cell>);
|
||||
impl World for Fixture {
|
||||
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
|
||||
self.0
|
||||
.get(&p)?
|
||||
.outline
|
||||
.bounds()
|
||||
.map(|b| b.move_by(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)))
|
||||
}
|
||||
fn collision_shapes(&self, _: Aabb) -> Vec<VoxelShape> {
|
||||
self.0
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
c.collision
|
||||
.moved(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn suffocating(&self, p: [i32; 3]) -> bool {
|
||||
self.0.get(&p).is_some_and(|c| c.suffocating)
|
||||
}
|
||||
}
|
||||
fn shape(value: &Value) -> Result<VoxelShape> {
|
||||
let mut shape = VoxelShape::empty();
|
||||
for b in value.as_array().unwrap() {
|
||||
let f = |i: usize| b[i].as_f64().unwrap();
|
||||
shape = shape.join(
|
||||
&VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(f(0), f(1), f(2)),
|
||||
Vec3::new(f(3), f(4), f(5)),
|
||||
))?,
|
||||
ShapeOp::OR,
|
||||
)?;
|
||||
}
|
||||
Ok(shape)
|
||||
}
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let mut world = Fixture(HashMap::new());
|
||||
for c in row["cells"].as_array().unwrap() {
|
||||
let p = std::array::from_fn(|i| c[i].as_i64().unwrap() as i32);
|
||||
world.0.insert(
|
||||
p,
|
||||
Cell {
|
||||
outline: shape(&c[3])?,
|
||||
collision: shape(&c[4])?,
|
||||
suffocating: c[5].as_bool().unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let p = &row["pos"];
|
||||
let (x, y, z) = (
|
||||
p[0].as_f64().unwrap(),
|
||||
p[1].as_f64().unwrap(),
|
||||
p[2].as_f64().unwrap(),
|
||||
);
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(x - 0.5, y, z - 0.5),
|
||||
Vec3::new(x + 0.5, y + 0.25, z + 0.5),
|
||||
);
|
||||
writeln!(
|
||||
out,
|
||||
"{} {}",
|
||||
cushion::can_place(&world, bounds),
|
||||
cushion::survives(&world, bounds)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -28,6 +28,7 @@ mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
mod emitter_particles_probe;
|
||||
mod enchanting_fixture;
|
||||
mod entity_anim_fixture;
|
||||
@@ -107,6 +108,7 @@ probes! {
|
||||
block_transform_fixture,
|
||||
breaking_fixture,
|
||||
camera_probe,
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
cloud_fixture,
|
||||
|
||||
@@ -7,7 +7,7 @@ use horizon_client::{
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
models,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
@@ -161,7 +161,15 @@ fn scene(
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let ([x, y, z], [u, w]) = (v.pos, v.uv());
|
||||
json!([x, y, z, u, w, argb(v.color), light_coords(v.light)])
|
||||
json!([
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
u,
|
||||
w,
|
||||
argb(v.color | EMISSION_BITS),
|
||||
light_coords(v.light)
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices}));
|
||||
|
||||
@@ -58,7 +58,10 @@ enum Animate {
|
||||
BubbleColumn {
|
||||
drag: bool,
|
||||
},
|
||||
Fire,
|
||||
/// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
|
||||
Fire {
|
||||
soul: bool,
|
||||
},
|
||||
/// An active sculk sensor.
|
||||
SculkSensor,
|
||||
/// Potent sulfur that is not dry.
|
||||
@@ -140,7 +143,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"bubble_column" => Animate::BubbleColumn {
|
||||
drag: prop("drag").as_deref() == Some("true"),
|
||||
},
|
||||
"fire" | "soul_fire" => Animate::Fire,
|
||||
"fire" => Animate::Fire { soul: false },
|
||||
"soul_fire" => Animate::Fire { soul: true },
|
||||
"dried_ghast" => Animate::DriedGhast {
|
||||
waterlogged: prop("waterlogged").as_deref() == Some("true"),
|
||||
},
|
||||
@@ -190,6 +194,8 @@ pub struct AnimateTables {
|
||||
power_transmitter: Vec<bool>,
|
||||
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
|
||||
dried_ghast_sounds: Vec<bool>,
|
||||
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
|
||||
burnable: Vec<bool>,
|
||||
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
|
||||
ambient: Vec<Vec<(T, f32)>>,
|
||||
}
|
||||
@@ -220,6 +226,21 @@ impl AnimateTables {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let behaviour: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
|
||||
let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
|
||||
let burnable = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
// FireBlock.getIgniteOdds: a waterlogged state never burns.
|
||||
registry.state(BlockStateId(id)).is_some_and(|s| {
|
||||
s.properties.get("waterlogged").map(String::as_str) != Some("true")
|
||||
&& ignite_odds
|
||||
.and_then(|m| m.get(s.name.as_str()))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.is_some_and(|odds| odds > 0)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut ambient = vec![Vec::new(); biomes.list.len()];
|
||||
for (name, &id) in &biomes.index {
|
||||
let path = root.join(format!(
|
||||
@@ -248,6 +269,7 @@ impl AnimateTables {
|
||||
power_provider: by_state("minecraft:enchantment_power_provider"),
|
||||
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
|
||||
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
|
||||
burnable,
|
||||
ambient,
|
||||
})
|
||||
}
|
||||
@@ -1103,7 +1125,7 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Fire => {
|
||||
Animate::Fire { soul } => {
|
||||
let r = engine.random();
|
||||
if next_int(r, 24) == 0 {
|
||||
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
|
||||
@@ -1115,6 +1137,55 @@ fn block_animate_tick(
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
// BaseFireBlock.animateTick: three large smoke above the fire, or
|
||||
// two at each burnable neighbour's face when nothing burnable or
|
||||
// sturdy supports it (SoulFireBlock.canBurn is always true).
|
||||
let can_burn = |p: [i32; 3]| {
|
||||
soul || tables
|
||||
.burnable
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
|
||||
if !can_burn(below) && !sturdy {
|
||||
// (neighbour, coordinate axis, face at the axis' high end): the
|
||||
// west, east, north, south and above faces, in vanilla order.
|
||||
for (at, axis, high) in [
|
||||
([pos[0] - 1, pos[1], pos[2]], 0usize, false),
|
||||
([pos[0] + 1, pos[1], pos[2]], 0, true),
|
||||
([pos[0], pos[1], pos[2] - 1], 2, false),
|
||||
([pos[0], pos[1], pos[2] + 1], 2, true),
|
||||
([pos[0], pos[1] + 1, pos[2]], 1, true),
|
||||
] {
|
||||
if !can_burn(at) {
|
||||
continue;
|
||||
}
|
||||
for _ in 0..2 {
|
||||
let r = engine.random();
|
||||
let d = [r.next_double(), r.next_double(), r.next_double()];
|
||||
let mut p = [fx + d[0], fy + d[1], fz + d[2]];
|
||||
// The face coordinate hugs the edge: `* 0.1F` (float).
|
||||
p[axis] = if high {
|
||||
f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
|
||||
} else {
|
||||
f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
|
||||
};
|
||||
add(engine, T::LargeSmoke, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _ in 0..3 {
|
||||
let r = engine.random();
|
||||
let p = [
|
||||
fx + r.next_double(),
|
||||
fy + r.next_double() * 0.5 + 0.5,
|
||||
fz + r.next_double(),
|
||||
];
|
||||
add(engine, T::LargeSmoke, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
"minecraft:trial_spawner" => {
|
||||
// `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional
|
||||
// and defaulted by vanilla; they are kept as loaded.
|
||||
// and defaulted by vanilla; they are kept as loaded. No `spawn_data` is
|
||||
// added: an absent one loads as Optional.empty and is not stored, and
|
||||
// an empty entity would keep the spawner from ever picking a mob.
|
||||
if let Tag::Compound(c) = tag {
|
||||
for (k, v) in c {
|
||||
let key = k.to_string_lossy();
|
||||
@@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !f.out.contains_key(&"spawn_data".into()) {
|
||||
f.put(
|
||||
"spawn_data",
|
||||
Tag::compound([("entity", Tag::Compound(Compound::new()))]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:vault" => {
|
||||
// `VaultConfig.DEFAULT` (when the config is absent): the trial key and the
|
||||
@@ -827,6 +823,21 @@ mod tests {
|
||||
canonical(&snbt(s)).expect("known type")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trial_spawner_without_spawn_data_saves_none() {
|
||||
let fresh = canon(
|
||||
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#,
|
||||
);
|
||||
assert_eq!(fresh.get("spawn_data"), None);
|
||||
let picked = canon(
|
||||
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
picked.get("spawn_data"),
|
||||
Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_type_has_defaults() {
|
||||
for id in TYPES.iter().chain(NEWEST.iter()) {
|
||||
|
||||
@@ -558,12 +558,11 @@ fn frac(x: f32) -> f32 {
|
||||
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
|
||||
/// wrapped at the last space that fits or mid-word when none does.
|
||||
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut width = 0.0;
|
||||
let mut last_space = None;
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
for (i, c) in line.char_indices() {
|
||||
if c == '\n' {
|
||||
return chars[..i].iter().collect();
|
||||
return line[..i].to_owned();
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
@@ -572,9 +571,11 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
|
||||
if width > max_width {
|
||||
let end = match last_space {
|
||||
Some(s) if s > 0 => s,
|
||||
_ => i.max(1),
|
||||
_ if i > 0 => i,
|
||||
// The first char alone is too wide: take that one char.
|
||||
_ => c.len_utf8(),
|
||||
};
|
||||
return chars[..end].iter().collect();
|
||||
return line[..end].to_owned();
|
||||
}
|
||||
}
|
||||
line.to_owned()
|
||||
@@ -2098,4 +2099,13 @@ mod tests {
|
||||
assert_eq!(out[1].pos, [2.0, 0.0, 0.0]);
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_wraps_on_bytes_without_splitting_a_char() {
|
||||
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
|
||||
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,38 @@ impl Contents {
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// The stacks inside, in display order (the first is the next out).
|
||||
pub fn items(&self) -> &[ItemStack] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// `getNumberOfItemsToShow`: a full grid shows 11 and a `+N`, so the last
|
||||
/// row keeps its fourth cell for the count.
|
||||
pub fn shown_count(&self) -> usize {
|
||||
let count = self.items.len();
|
||||
let available: usize = if count > 12 { 11 } else { 12 };
|
||||
let on_last_row = count % 4;
|
||||
let empty = if on_last_row == 0 { 0 } else { 4 - on_last_row };
|
||||
count.min(available.saturating_sub(empty))
|
||||
}
|
||||
|
||||
/// The items the tooltip shows, and how many sit beyond them.
|
||||
pub fn shown(&self) -> (&[ItemStack], u32) {
|
||||
let n = self.shown_count();
|
||||
let hidden = self.items[n..].iter().map(|s| s.count).sum();
|
||||
(&self.items[..n], hidden)
|
||||
}
|
||||
|
||||
/// The `Fraction` weight as the float the progress bar reads.
|
||||
pub fn weight_fraction(&self) -> f32 {
|
||||
self.weight.n as f32 / self.weight.d as f32
|
||||
}
|
||||
|
||||
/// `Fraction.ONE`: a full bundle draws the full bar and says so.
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.weight.n >= self.weight.d
|
||||
}
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
bundle.data_mut().bundle_contents = self.items;
|
||||
@@ -190,3 +222,174 @@ mod tests {
|
||||
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- the tooltip
|
||||
|
||||
/// `ClientBundleTooltip`'s geometry, relative to the tooltip box's top-left:
|
||||
/// the 96 pixel column of a 4 wide slot grid and the progress bar under it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TooltipLayout {
|
||||
/// The component's own width (96) and height, the text's excluded.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// Each slot's top-left and the stack in it, in reading order.
|
||||
pub slots: Vec<([f32; 2], ItemStack)>,
|
||||
/// The `+N` of the items past the grid: its top-left and the count.
|
||||
pub overflow: Option<([f32; 2], u32)>,
|
||||
/// The progress bar's top-left, its fill's width out of 94, and whether
|
||||
/// the weight fills it (the full sprite).
|
||||
pub bar: [f32; 2],
|
||||
pub fill: f32,
|
||||
pub full: bool,
|
||||
/// `item.minecraft.bundle.empty` / `.full`, centred over the bar.
|
||||
pub label: Option<&'static str>,
|
||||
/// An empty bundle's description, wrapped at `width` from here.
|
||||
pub empty_description: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// The tooltip of a bundle's contents (`ClientBundleTooltip`), or None when
|
||||
/// the stack is not a bundle. `x`/`y` are the box's top-left, `box_width` the
|
||||
/// tooltip's width, which the component is centred in.
|
||||
pub fn tooltip_layout(
|
||||
items: &Items,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
box_width: f32,
|
||||
) -> Option<TooltipLayout> {
|
||||
let contents = Contents::of(items, stack)?;
|
||||
let offset = ((box_width - 96.0) / 2.0).floor();
|
||||
if contents.items().is_empty() {
|
||||
// The description takes the top; the bar follows it.
|
||||
let description_height = 9.0 * 2.0;
|
||||
return Some(TooltipLayout {
|
||||
width: 96.0,
|
||||
height: description_height + 13.0 + 8.0,
|
||||
slots: Vec::new(),
|
||||
overflow: None,
|
||||
bar: [x + offset, y + description_height + 4.0],
|
||||
fill: 0.0,
|
||||
full: false,
|
||||
label: Some("item.minecraft.bundle.empty"),
|
||||
empty_description: Some([x + offset, y]),
|
||||
});
|
||||
}
|
||||
let (shown, hidden) = contents.shown();
|
||||
// `gridSizeY` counts `slotCount()` (at most twelve), not the slots the
|
||||
// overflow count leaves room for.
|
||||
let rows = contents.items().len().min(12).div_ceil(4).max(1);
|
||||
let grid_height = rows as f32 * 24.0;
|
||||
let x_start = x + offset + 96.0;
|
||||
let y_start = y + grid_height;
|
||||
let mut slots = Vec::with_capacity(shown.len());
|
||||
let mut overflow = None;
|
||||
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
|
||||
let mut slot = 0;
|
||||
for row in 1..=rows {
|
||||
for column in 1..=4 {
|
||||
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
|
||||
if hidden > 0 && row == 1 && column == 1 {
|
||||
overflow = Some((at, hidden));
|
||||
} else if let Some(stack) = shown.get(slot) {
|
||||
slots.push((at, stack.clone()));
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let weight = contents.weight_fraction();
|
||||
Some(TooltipLayout {
|
||||
width: 96.0,
|
||||
height: grid_height + 13.0 + 8.0,
|
||||
slots,
|
||||
overflow,
|
||||
bar: [x + offset, y + grid_height + 4.0],
|
||||
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
|
||||
fill: (weight * 94.0).trunc().clamp(0.0, 94.0),
|
||||
full: contents.is_full(),
|
||||
label: if contents.is_full() {
|
||||
Some("item.minecraft.bundle.full")
|
||||
} else {
|
||||
None
|
||||
},
|
||||
empty_description: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tooltip_tests {
|
||||
use super::*;
|
||||
|
||||
/// The prepared resources, when they are on this machine: the tooltip
|
||||
/// asks the item registry whether a stack is a bundle.
|
||||
fn items() -> Option<Items> {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return None;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).ok()?;
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?;
|
||||
Items::load(&root, assets.registry, &facts).ok()
|
||||
}
|
||||
|
||||
fn bundle_with(counts: &[(&str, u32)]) -> ItemStack {
|
||||
let mut bundle = ItemStack::new("minecraft:bundle", 1);
|
||||
bundle.data_mut().bundle_contents = counts
|
||||
.iter()
|
||||
.map(|(item, n)| ItemStack::new(item, *n))
|
||||
.collect();
|
||||
bundle
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_shows_twelve_stacks_and_counts_the_rest() {
|
||||
let Some(items) = items() else { return };
|
||||
let twelve: Vec<(&str, u32)> = (0..12).map(|_| ("minecraft:stone", 1)).collect();
|
||||
let layout = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 96.0).unwrap();
|
||||
assert_eq!(layout.slots.len(), 12);
|
||||
assert_eq!(layout.overflow, None);
|
||||
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
|
||||
// Rows fill bottom-up and columns right-to-left: the first item is in
|
||||
// the bottom-right cell, the twelfth in the top-left.
|
||||
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
|
||||
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
|
||||
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
|
||||
// A wider tooltip centres the 96 pixel column in it.
|
||||
let centred = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 120.0).unwrap();
|
||||
assert_eq!(centred.slots[0].0[0], 12.0 + 96.0 - 24.0);
|
||||
// Thirteen stacks: `getNumberOfItemsToShow` leaves the last row room
|
||||
// for the count, so 8 slots show and `+6` (four stones and two dirt)
|
||||
// takes the first cell.
|
||||
let mut counts = twelve;
|
||||
counts.push(("minecraft:dirt", 2));
|
||||
let layout = tooltip_layout(&items, &bundle_with(&counts), 0.0, 0.0, 96.0).unwrap();
|
||||
assert_eq!(layout.slots.len(), 8);
|
||||
assert_eq!(layout.overflow, Some(([96.0 - 24.0, 48.0], 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bar_fills_with_the_weight_and_an_empty_bundle_explains_itself() {
|
||||
let Some(items) = items() else { return };
|
||||
let layout = tooltip_layout(&items, &bundle_with(&[]), 4.0, 8.0, 96.0).unwrap();
|
||||
assert!(layout.slots.is_empty());
|
||||
assert_eq!(layout.fill, 0.0);
|
||||
assert_eq!(layout.label, Some("item.minecraft.bundle.empty"));
|
||||
assert_eq!(layout.empty_description, Some([4.0, 8.0]));
|
||||
assert_eq!(layout.bar, [4.0, 8.0 + 18.0 + 4.0]);
|
||||
// A bundle holding one 64 stack is full: the whole bar, the full label.
|
||||
let layout = tooltip_layout(
|
||||
&items,
|
||||
&bundle_with(&[("minecraft:stone", 64)]),
|
||||
0.0,
|
||||
0.0,
|
||||
96.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(layout.full);
|
||||
assert_eq!(layout.fill, 94.0);
|
||||
assert_eq!(layout.label, Some("item.minecraft.bundle.full"));
|
||||
// Something that is not a bundle has no component.
|
||||
let dirt = ItemStack::new("minecraft:dirt", 1);
|
||||
assert!(tooltip_layout(&items, &dirt, 0.0, 0.0, 96.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,11 @@ mod tests {
|
||||
assert_eq!(filter_text(s, true), "abc\nde\u{80}");
|
||||
assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§");
|
||||
assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo");
|
||||
// TextFieldHelper.getClipboardContents runs before insertText's filter.
|
||||
assert_eq!(
|
||||
filter_text(&helper_clipboard_contents("§aHello"), false),
|
||||
"Hello"
|
||||
);
|
||||
}
|
||||
|
||||
struct Field {
|
||||
|
||||
@@ -275,4 +275,30 @@ mod tests {
|
||||
Release::Click(Input::QuickMove)
|
||||
);
|
||||
}
|
||||
|
||||
/// A press whose release never arrives (the pointer left the window, or
|
||||
/// the screen closed first) leaves `isQuickCrafting` set; the next screen
|
||||
/// starts with its own state, as `AbstractContainerScreen` does, so the
|
||||
/// reset the game runs on open/close makes clicks work again.
|
||||
#[test]
|
||||
fn a_lost_release_does_not_wedge_the_next_screen() {
|
||||
let mut clicks = Clicks::default();
|
||||
let slot = Target::<usize>::Slot(3);
|
||||
// Pressed with a stack carried: a quick craft drag starts, and its
|
||||
// release is lost.
|
||||
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
|
||||
assert!(clicks.quick_crafting().is_some());
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
None,
|
||||
"the stuck drag swallows the next press"
|
||||
);
|
||||
// A new screen: fresh state, so the press is a normal click again.
|
||||
clicks = Clicks::default();
|
||||
assert!(clicks.quick_crafting().is_none());
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,17 @@ pub struct Furnace {
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
|
||||
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
|
||||
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
|
||||
/// ("Duplicate entry for key").
|
||||
pub fn recipe_used_key(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
@@ -83,9 +94,66 @@ pub enum BlockEntity {
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
|
||||
let Some(slot) = self.items.iter().position(Option::is_none) else {
|
||||
return false;
|
||||
};
|
||||
self.total[slot] = cooking_time;
|
||||
self.progress[slot] = 0;
|
||||
self.items[slot] = Some(ItemStack {
|
||||
count: 1,
|
||||
..held.clone()
|
||||
});
|
||||
held.count = held.count.saturating_sub(1);
|
||||
true
|
||||
}
|
||||
/// `cookTick` (lit): each item's progress rises by one; the slots whose
|
||||
/// progress reached their cooking time give up their item (the caller
|
||||
/// drops its result).
|
||||
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
|
||||
let mut done = Vec::new();
|
||||
for i in 0..4 {
|
||||
if self.items[i].is_none() {
|
||||
continue;
|
||||
}
|
||||
self.progress[i] += 1;
|
||||
if self.progress[i] >= self.total[i]
|
||||
&& let Some(item) = self.items[i].take()
|
||||
{
|
||||
done.push((i, item));
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
|
||||
/// Whether anything changed (`setChanged`).
|
||||
pub fn cooldown_tick(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
for i in 0..4 {
|
||||
if self.progress[i] > 0 {
|
||||
changed = true;
|
||||
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
@@ -304,6 +372,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:brewing_stand" {
|
||||
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
|
||||
}
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -341,6 +412,7 @@ impl BlockEntity {
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -362,6 +434,7 @@ impl BlockEntity {
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -382,7 +455,9 @@ impl BlockEntity {
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &[],
|
||||
| Self::CommandBlock(_)
|
||||
// Not a Container (only Clearable): hoppers don't reach in.
|
||||
| Self::Campfire(_) => &[],
|
||||
}
|
||||
}
|
||||
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
|
||||
@@ -403,7 +478,8 @@ impl BlockEntity {
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &mut [],
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Campfire(_) => &mut [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +714,10 @@ impl Furnace {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
@@ -692,6 +771,41 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
let mut beef = ItemStack::new("minecraft:beef", 2);
|
||||
// placeFood: the first free slot, one item, its recipe's time.
|
||||
assert!(c.place_food(&mut beef, 600));
|
||||
assert_eq!(beef.count, 1);
|
||||
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
|
||||
assert_eq!((c.progress[0], c.total[0]), (0, 600));
|
||||
let mut fish = ItemStack::new("minecraft:cod", 1);
|
||||
assert!(c.place_food(&mut fish, 3));
|
||||
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
|
||||
// cookTick: the cod is done on its third tick, the beef keeps cooking.
|
||||
assert!(c.cook_tick().is_empty());
|
||||
assert!(c.cook_tick().is_empty());
|
||||
let done = c.cook_tick();
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
|
||||
assert!(c.items[1].is_none());
|
||||
assert_eq!(c.progress[0], 3);
|
||||
// cooldownTick: two a tick, never below zero.
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 1);
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 0);
|
||||
// Full: four slots, no fifth.
|
||||
for _ in 0..3 {
|
||||
let mut more = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(c.place_food(&mut more, 600));
|
||||
}
|
||||
let mut fifth = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(!c.place_food(&mut fifth, 600));
|
||||
assert_eq!(fifth.count, 1);
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
|
||||
@@ -1612,7 +1612,9 @@ impl Recipes {
|
||||
}
|
||||
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
|
||||
/// pack does not have (a save from another pack can name it).
|
||||
/// `id` with or without the `minecraft:` namespace (a furnace keeps the full id).
|
||||
pub fn cooking_experience(&self, id: &str) -> f32 {
|
||||
let id = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
self.cooking
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
|
||||
//! contents as built by the game's own display generators
|
||||
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
|
||||
use crate::{Result, items::ItemStack};
|
||||
use crate::{Result, items::ItemStack, ui::EditBox};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
@@ -35,10 +35,8 @@ pub struct Creative {
|
||||
pub scroll: f32,
|
||||
/// The scrollbar is held (scrolling): dragging moves it.
|
||||
pub scrolling: bool,
|
||||
pub search: String,
|
||||
/// When the search box took the focus (`EditBox.setFocused`): its
|
||||
/// cursor blinks from then.
|
||||
pub search_focused: std::time::Instant,
|
||||
/// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50.
|
||||
pub search: EditBox,
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
@@ -284,8 +282,13 @@ impl Creative {
|
||||
selected: 0,
|
||||
scroll: 0.0,
|
||||
scrolling: false,
|
||||
search: String::new(),
|
||||
search_focused: std::time::Instant::now(),
|
||||
search: {
|
||||
let mut search = EditBox::new(0.0, 0.0, 80.0, 9.0);
|
||||
search.bordered = false;
|
||||
search.max_length = 50;
|
||||
search.text_color = u32::MAX;
|
||||
search
|
||||
},
|
||||
search_items,
|
||||
search_index,
|
||||
}
|
||||
@@ -306,12 +309,13 @@ impl Creative {
|
||||
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
|
||||
pub fn select(&mut self, tab: usize) {
|
||||
if tab != self.selected {
|
||||
self.search.clear();
|
||||
self.search.set_value("");
|
||||
}
|
||||
self.selected = tab;
|
||||
self.scroll = 0.0;
|
||||
if self.tab().search {
|
||||
self.search_focused = std::time::Instant::now();
|
||||
self.search.focused = true;
|
||||
self.search.reset_blink();
|
||||
}
|
||||
}
|
||||
/// The search tab's stacks.
|
||||
@@ -385,7 +389,7 @@ impl Creative {
|
||||
let tab = self.tab();
|
||||
if tab.search {
|
||||
return self
|
||||
.search_results(&self.search, names)
|
||||
.search_results(&self.search.value, names)
|
||||
.into_iter()
|
||||
.map(|i| self.search_items[i].clone())
|
||||
.collect();
|
||||
@@ -754,13 +758,13 @@ mod tests {
|
||||
(1, false, true),
|
||||
(2, true, false),
|
||||
] {
|
||||
c.search = "anvil".into();
|
||||
c.search.set_value("anvil");
|
||||
c.scroll = 0.5;
|
||||
c.select(selected);
|
||||
assert_eq!(c.selected, selected);
|
||||
assert_eq!(c.tab().inventory, inventory);
|
||||
assert_eq!(c.tab().search, search);
|
||||
assert!(c.search.is_empty());
|
||||
assert!(c.search.value.is_empty());
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -780,12 +784,12 @@ mod tests {
|
||||
assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil");
|
||||
let mut c = c;
|
||||
c.select(1);
|
||||
c.search = "anvil".into();
|
||||
c.search.set_value("anvil");
|
||||
c.scroll = 0.5;
|
||||
c.select(1);
|
||||
assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0));
|
||||
assert_eq!((c.search.value.as_str(), c.scroll), ("anvil", 0.0));
|
||||
c.select(0);
|
||||
assert_eq!(c.search, "");
|
||||
assert_eq!(c.search.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -899,4 +903,25 @@ mod tests {
|
||||
c.scroll = 1.0;
|
||||
assert_eq!(c.scroller_offset(), 95.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_edits_at_its_cursor() {
|
||||
use winit::keyboard::KeyCode;
|
||||
let mut c = creative(0);
|
||||
c.select(1);
|
||||
c.search.insert_text("hello world");
|
||||
for _ in 0..5 {
|
||||
assert!(c.search.key(KeyCode::ArrowLeft, false, false));
|
||||
}
|
||||
c.search.insert_text("X");
|
||||
assert_eq!(c.search.value, "hello Xworld");
|
||||
c.search.key(KeyCode::Home, false, true);
|
||||
c.search.insert_text("Y");
|
||||
assert_eq!(c.search.value, "Yworld");
|
||||
// Ctrl+Left jumps a word; one step right and Backspace deletes before the cursor.
|
||||
c.search.key(KeyCode::ArrowLeft, true, false);
|
||||
c.search.key(KeyCode::ArrowRight, false, false);
|
||||
c.search.key(KeyCode::Backspace, false, false);
|
||||
assert_eq!(c.search.value, "world");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Cushion placement and support checks (`Cushion.canBePlacedAt`/`wouldSurviveAt`).
|
||||
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
|
||||
|
||||
pub trait World {
|
||||
fn outline_bounds(&self, pos: [i32; 3]) -> Option<Aabb>;
|
||||
fn collision_shapes(&self, bounds: Aabb) -> Vec<VoxelShape>;
|
||||
fn suffocating(&self, pos: [i32; 3]) -> bool;
|
||||
}
|
||||
|
||||
pub fn blocks_in(a: Aabb) -> impl Iterator<Item = [i32; 3]> {
|
||||
let (lo, hi) = (a.min_position(), a.max_position());
|
||||
let (x0, y0, z0) = (
|
||||
lo.x.floor() as i32,
|
||||
lo.y.floor() as i32,
|
||||
lo.z.floor() as i32,
|
||||
);
|
||||
let (x1, y1, z1) = (
|
||||
hi.x.floor() as i32,
|
||||
hi.y.floor() as i32,
|
||||
hi.z.floor() as i32,
|
||||
);
|
||||
(y0..=y1).flat_map(move |y| (z0..=z1).flat_map(move |z| (x0..=x1).map(move |x| [x, y, z])))
|
||||
}
|
||||
|
||||
pub fn survives(world: &impl World, bounds: Aabb) -> bool {
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let anchor = Aabb::new(
|
||||
Vec3::new(lo.x, lo.y - 0.015625, lo.z),
|
||||
Vec3::new(hi.x.next_down(), lo.y, hi.z.next_down()),
|
||||
);
|
||||
// The extra eighth block expands the scan, not the intersection test.
|
||||
let supported = blocks_in(anchor.expand_towards(Vec3::new(0.0, -0.125, 0.0))).any(|p| {
|
||||
world
|
||||
.outline_bounds(p)
|
||||
.is_some_and(|shape| shape.intersects(anchor))
|
||||
});
|
||||
supported && !blocks_in(bounds.next_deflated()).all(|p| world.suffocating(p))
|
||||
}
|
||||
|
||||
pub fn can_place(world: &impl World, bounds: Aabb) -> bool {
|
||||
if !survives(world, bounds) {
|
||||
return false;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let slab = Aabb::new(lo, Vec3::new(hi.x, lo.y + 0.015625, hi.z)).next_deflated();
|
||||
let Ok(mut remaining) = VoxelShape::from_box(slab) else {
|
||||
return false;
|
||||
};
|
||||
for shape in world.collision_shapes(slab) {
|
||||
let Ok(next) = remaining.join(&shape, ShapeOp::ONLY_FIRST) else {
|
||||
return false;
|
||||
};
|
||||
remaining = next;
|
||||
if remaining.is_empty() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -591,6 +591,57 @@ impl Painting {
|
||||
)
|
||||
}
|
||||
}
|
||||
/// The `DyeColor` ordinal of a colour name (`DyeColor.byName`), white when
|
||||
/// the name is unknown.
|
||||
pub fn dye_index(name: &str) -> u8 {
|
||||
crate::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
.position(|n| *n == name)
|
||||
.unwrap_or(0) as u8
|
||||
}
|
||||
/// A cushion (`Cushion`, a `BlockAttachedEntity`): a 1 x 0.25 block pillow
|
||||
/// that seats a player. It sits on the top face of a block, at the clicked
|
||||
/// height, and is coloured by its `DyeColor`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cushion {
|
||||
pub id: u32,
|
||||
/// `Entity.position`: the bottom centre of the cushion.
|
||||
pub position: Vec3,
|
||||
/// `BlockAttachedEntity.pos` (the saved `block_pos`).
|
||||
pub pos: [i32; 3],
|
||||
pub yaw: f32,
|
||||
/// `DyeColor` ordinal: white 0, orange 1, ..., black 15.
|
||||
pub color: u8,
|
||||
/// BlockAttachedEntity.ticksSinceLastCheck.
|
||||
pub check_interval: u32,
|
||||
}
|
||||
impl Cushion {
|
||||
/// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25.
|
||||
pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
|
||||
let p = self.position;
|
||||
(
|
||||
[p.x - 0.5, p.y, p.z - 0.5],
|
||||
[p.x + 0.5, p.y + 0.25, p.z + 0.5],
|
||||
)
|
||||
}
|
||||
/// The `DyeColor` name of the cushion.
|
||||
pub fn color_name(&self) -> &'static str {
|
||||
crate::mobs::render::DYE_NAMES[usize::from(self.color.min(15))]
|
||||
}
|
||||
/// `Cushion.getCushionItemStackWithData`: the matching coloured item.
|
||||
pub fn item(&self) -> String {
|
||||
format!("minecraft:{}_cushion", self.color_name())
|
||||
}
|
||||
/// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment
|
||||
/// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`.
|
||||
pub fn seat(&self) -> Vec3 {
|
||||
Vec3::new(self.position.x, self.position.y - 0.35, self.position.z)
|
||||
}
|
||||
/// `Entity.getDismountLocationForPassenger`: on top of the cushion.
|
||||
pub fn dismount(&self) -> Vec3 {
|
||||
Vec3::new(self.position.x, self.position.y + 0.25, self.position.z)
|
||||
}
|
||||
}
|
||||
/// An item frame or glow item frame (`ItemFrame`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemFrame {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! survive a Horizon save.
|
||||
use crate::{
|
||||
arrows::Pickup,
|
||||
entities::{ArmorStand, ExperienceOrb, ItemEntity, ItemFrame, Painting},
|
||||
entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting},
|
||||
mobs::{Mob, MobType, Who},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
save::{
|
||||
@@ -1313,6 +1313,79 @@ pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
|
||||
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
|
||||
Some((pos, facing, text(tag.get("variant"))?))
|
||||
}
|
||||
/// Cushion.addAdditionalSaveData: the block it sits on and its colour.
|
||||
pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: "minecraft:cushion",
|
||||
uuid,
|
||||
position: c.position,
|
||||
motion: Vec3::ZERO,
|
||||
rotation: [c.yaw, 0.0],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec()));
|
||||
put(&mut tag, "color", string(c.color_name()));
|
||||
tag
|
||||
}
|
||||
/// The position, block position, yaw and colour of a saved cushion.
|
||||
pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option<Cushion> {
|
||||
let position = position(tag)?;
|
||||
let pos = match tag.get("block_pos") {
|
||||
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
|
||||
_ => [
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
],
|
||||
};
|
||||
let yaw = match tag.get("Rotation") {
|
||||
Some(Tag::List(l)) => l
|
||||
.first()
|
||||
.and_then(|v| crate::save::float(Some(v)))
|
||||
.unwrap_or(0.0) as f32,
|
||||
_ => 0.0,
|
||||
};
|
||||
let color =
|
||||
crate::entities::dye_index(&text(tag.get("color")).unwrap_or_else(|| "white".into()));
|
||||
Some(Cushion {
|
||||
id,
|
||||
position,
|
||||
pos,
|
||||
yaw,
|
||||
color,
|
||||
check_interval: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cushion_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cushion_save_preserves_color_anchor_rotation_and_unknown_data() {
|
||||
let cushion = Cushion {
|
||||
id: 7,
|
||||
position: Vec3::new(-1.5, 64.5, 3.5),
|
||||
pos: [-2, 64, 3],
|
||||
yaw: 270.0,
|
||||
color: 14,
|
||||
check_interval: 55,
|
||||
};
|
||||
let raw = Tag::compound([("CustomName", string("Seat"))]);
|
||||
let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw));
|
||||
assert_eq!(text(tag.get("CustomName")).as_deref(), Some("Seat"));
|
||||
let loaded = cushion_from_nbt(&tag, 8).unwrap();
|
||||
assert_eq!(loaded.id, 8);
|
||||
assert_eq!(loaded.position, cushion.position);
|
||||
assert_eq!(loaded.pos, cushion.pos);
|
||||
assert_eq!(loaded.yaw, cushion.yaw);
|
||||
assert_eq!(loaded.color_name(), "red");
|
||||
assert_eq!(loaded.check_interval, 0);
|
||||
assert_eq!(loaded.bounds(), cushion.bounds());
|
||||
}
|
||||
}
|
||||
/// ItemFrame.addAdditionalSaveData.
|
||||
pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let (lo, hi) = f.bounds();
|
||||
|
||||
@@ -18,6 +18,7 @@ mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
mod breezes;
|
||||
mod campfire;
|
||||
mod cartography;
|
||||
mod chat;
|
||||
mod client_ambient;
|
||||
@@ -33,6 +34,7 @@ mod copper_golems;
|
||||
mod creaking;
|
||||
mod creatures;
|
||||
mod crossbow;
|
||||
pub(crate) mod cushions;
|
||||
mod debug_keys;
|
||||
mod decorated_pot;
|
||||
mod dedicated;
|
||||
@@ -80,6 +82,7 @@ mod lan_mobs;
|
||||
mod lan_mount_menu;
|
||||
mod lan_particles;
|
||||
mod lan_pvp;
|
||||
mod lan_quiet;
|
||||
mod lan_screen;
|
||||
mod lan_stations;
|
||||
mod lan_trading;
|
||||
@@ -147,6 +150,7 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
@@ -1429,6 +1433,8 @@ struct Game<'a> {
|
||||
map_frame_model: frames::FrameModel,
|
||||
glow_map_frame_model: frames::FrameModel,
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
/// `Cushion` decoration entities (game/cushions.rs).
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
/// Entities of types Horizon does not simulate.
|
||||
others: Vec<commands::OtherEntity>,
|
||||
armor_stand_assets: armor_stands::ArmorStandAssets,
|
||||
@@ -1444,6 +1450,8 @@ struct Game<'a> {
|
||||
boat_models: boats::BoatModels,
|
||||
/// EndCrystalModel's baked layer.
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
/// CushionModel's baked layer.
|
||||
cushion_model: Option<horizon_client::mobs::Part>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
/// Songs playing from jukeboxes (tracked sound handles).
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
@@ -2787,6 +2795,9 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.consume_teleport_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent { id: 2003, pos, .. } => {
|
||||
self.eye_break_particles([pos.x, pos.y, pos.z]);
|
||||
}
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2013,
|
||||
pos,
|
||||
@@ -3338,6 +3349,7 @@ impl Game<'_> {
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
|
||||
// ServerPlayerGameMode.destroyBlock.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let broken = state;
|
||||
let facts = self.res.facts().get(state).clone();
|
||||
let state = self.crack_mined_pot(pos, state);
|
||||
// Block.playerWillDestroy: mining their gold angers all nearby piglins.
|
||||
@@ -3369,6 +3381,9 @@ impl Game<'_> {
|
||||
creative,
|
||||
)
|
||||
});
|
||||
if removed {
|
||||
self.mod_block_event(pos, broken, false);
|
||||
}
|
||||
if removed && !creative {
|
||||
let held = self.held().cloned();
|
||||
self.mine_block_with_held(&facts.name, facts.hardness);
|
||||
@@ -3477,6 +3492,7 @@ impl Game<'_> {
|
||||
fn interact_entity(&mut self) -> Result<bool> {
|
||||
Ok(self.interact_frame()
|
||||
|| self.interact_armor_stand()
|
||||
|| self.interact_cushion()
|
||||
|| self.interact_boat()
|
||||
|| self.interact_cart()
|
||||
|| self.interact_mob()?
|
||||
@@ -3663,6 +3679,7 @@ impl Game<'_> {
|
||||
|| self.place_painting(hit, &stack)
|
||||
|| self.place_item_frame(hit, &stack)
|
||||
|| self.place_armor_stand(hit, &stack)
|
||||
|| self.place_cushion(hit, &stack)
|
||||
|| self.place_end_crystal(hit, &stack))
|
||||
{
|
||||
return Ok(true);
|
||||
@@ -3711,6 +3728,7 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
};
|
||||
let target = [pos.x, pos.y, pos.z];
|
||||
self.mod_block_event(target, state, true);
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
|
||||
let key = (target[0], target[1], target[2]);
|
||||
self.world.block_entities.insert(key, entity);
|
||||
@@ -4244,6 +4262,7 @@ impl Game<'_> {
|
||||
self.update_totem_ready();
|
||||
self.tick_command_extras();
|
||||
self.tick_mods();
|
||||
self.tick_server_mods();
|
||||
let camera_eye = self.camera_eye_height();
|
||||
let stick = self.pad_move();
|
||||
if self.riding.is_some() && self.player.is_some() {
|
||||
@@ -4546,6 +4565,7 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
|
||||
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
|
||||
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands());
|
||||
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
|
||||
horizon_client::spans::time("tick_boats", || self.tick_boats());
|
||||
horizon_client::spans::time("tick_carts", || self.tick_carts());
|
||||
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
|
||||
@@ -4754,7 +4774,10 @@ impl Game<'_> {
|
||||
if capture && !self.focused {
|
||||
return;
|
||||
}
|
||||
if !capture {
|
||||
if capture {
|
||||
// The inventory's screens are behind us: no drag survives them.
|
||||
self.reset_inventory_clicks();
|
||||
} else {
|
||||
self.stop_mining();
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
@@ -5280,7 +5303,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::Skull(_),
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::Campfire(_),
|
||||
) => {}
|
||||
None => {
|
||||
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
|
||||
@@ -5550,7 +5574,8 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::Skull(_),
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::Campfire(_),
|
||||
) => {}
|
||||
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
|
||||
None => {
|
||||
@@ -5593,7 +5618,18 @@ impl Game<'_> {
|
||||
self.draw_inventory_effects(batch, (ox, oy), 176);
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen; Horizon keeps them on the game, so they are
|
||||
/// dropped when a screen closes. A press whose release never reaches the
|
||||
/// screen (the pointer left the window, or the screen closed first) would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks = Default::default();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
pub(super) fn close_container(&mut self) {
|
||||
self.reset_inventory_clicks();
|
||||
self.close_station();
|
||||
self.mount_closed();
|
||||
self.recipe_ui.reset_screen();
|
||||
@@ -5689,8 +5725,9 @@ impl Game<'_> {
|
||||
if self.creative_screen.tab().search {
|
||||
// CreativeModeInventoryScreen.slotClicked: moveCursorToEnd, then
|
||||
// setHighlightPos(0), so typing replaces the whole query.
|
||||
let search = &self.creative_screen.search;
|
||||
self.text_selection.select(("creative.search", 0), search);
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.move_cursor_to_end(false);
|
||||
search.highlight = 0;
|
||||
}
|
||||
let items = self.creative_screen.items(&self.pack.names);
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
@@ -6332,6 +6369,13 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
if outside {
|
||||
// AbstractRecipeBookScreen.mouseClicked: the book takes the click
|
||||
// before the outside test, and its tabs sit left of the strip
|
||||
// hasClickedOutside counts as the book.
|
||||
if self.recipe_book_click(right) {
|
||||
self.inv_clicks.skip_release();
|
||||
return;
|
||||
}
|
||||
// slotId -999: an empty hand throws nothing; a carried stack
|
||||
// starts a drag whose release drops it.
|
||||
match input {
|
||||
@@ -6996,6 +7040,9 @@ impl Game<'_> {
|
||||
// `VaultRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.vault_geometry(camera, alpha, &mut out);
|
||||
// `CampfireRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.campfire_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
// Mobs and their projectiles (a span each).
|
||||
@@ -7238,6 +7285,7 @@ impl Game<'_> {
|
||||
self.frame_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:armor_stand");
|
||||
self.armor_stand_geometry(camera, alpha, &mut out);
|
||||
self.cushion_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:oak_boat");
|
||||
self.boat_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:minecart");
|
||||
@@ -7534,6 +7582,7 @@ impl Game<'_> {
|
||||
.map_or(0.0, |i| i.attack_damage);
|
||||
if self.attack_frame()
|
||||
|| self.attack_armor_stand()
|
||||
|| self.attack_cushion()
|
||||
|| self.attack_boat(hand_damage)
|
||||
|| self.attack_cart(hand_damage)
|
||||
|| self.attack_dragon()
|
||||
@@ -8278,6 +8327,7 @@ impl Game<'_> {
|
||||
self.boats.clear();
|
||||
self.carts.clear();
|
||||
self.armor_stands.clear();
|
||||
self.cushions.clear();
|
||||
self.item_frames.clear();
|
||||
self.paintings.clear();
|
||||
self.others.clear();
|
||||
@@ -8696,6 +8746,13 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(UseResult::Success { swing: true });
|
||||
}
|
||||
if allow_block_actions
|
||||
&& let Some(hit) = self.pick()
|
||||
&& let Some(placed) = self.use_on_campfire(hit.pos)?
|
||||
{
|
||||
// SUCCESS_SERVER swings; a full campfire's CONSUME does not.
|
||||
return Ok(UseResult::Success { swing: placed });
|
||||
}
|
||||
// BucketItem has no useOn: a guest's UseItemOn leaves it to UseItem.
|
||||
let bucket = horizon_client::items::bucket_contents(&stack.item);
|
||||
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
|
||||
@@ -9813,20 +9870,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
if tab.search {
|
||||
// The search box: an unbordered EditBox of 80 at (82, 6), white.
|
||||
let search = &self.creative_screen.search;
|
||||
horizon_client::ui::append_field(
|
||||
batch,
|
||||
atlas,
|
||||
search,
|
||||
(ox + 82.0, oy + 6.0, 80.0),
|
||||
(u32::MAX, true),
|
||||
clipboard::CREATIVE_SEARCH_CHARS,
|
||||
self.text_selection
|
||||
.covers(clipboard::CREATIVE_SEARCH, search),
|
||||
true,
|
||||
self.creative_screen.search_focused.elapsed().as_millis(),
|
||||
);
|
||||
// CreativeModeInventoryScreen: an unbordered EditBox of 50 at (82, 6).
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = ox + 82.0;
|
||||
search.y = oy + 6.0;
|
||||
search.width = 80.0;
|
||||
search.bordered = false;
|
||||
search.focused = true;
|
||||
search.text_color = u32::MAX;
|
||||
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
|
||||
search.draw(batch, atlas, None);
|
||||
}
|
||||
let mut hovered: Option<ItemStack> = None;
|
||||
if tab.inventory {
|
||||
@@ -9934,8 +9987,7 @@ impl Game<'_> {
|
||||
} else if self.carried.is_none()
|
||||
&& let Some(item) = hovered
|
||||
{
|
||||
let lines = self.stack_tooltip(&item);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &item, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
}
|
||||
@@ -9988,6 +10040,9 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !right && self.creative_search_click(ox, oy) {
|
||||
return;
|
||||
}
|
||||
// Holding the scrollbar scrolls as the mouse moves (mouseDragged).
|
||||
if !right
|
||||
&& self
|
||||
@@ -10000,6 +10055,24 @@ impl Game<'_> {
|
||||
self.inventory_press(right);
|
||||
}
|
||||
}
|
||||
/// A click on the search tab's box (`EditBox.onClick`).
|
||||
fn creative_search_click(&mut self, ox: f32, oy: f32) -> bool {
|
||||
if !self.creative_screen.tab().search {
|
||||
return false;
|
||||
}
|
||||
let (x, y, w, h) = (ox + 82.0, oy + 6.0, 80.0, 9.0);
|
||||
let (mx, my) = self.cursor;
|
||||
if mx < x || my < y || mx >= x + w || my >= y + h {
|
||||
return false;
|
||||
}
|
||||
let shift = self.has_shift_down();
|
||||
let search = &mut self.creative_screen.search;
|
||||
search.x = x;
|
||||
search.y = y;
|
||||
search.width = w;
|
||||
search.click(&self.gui.atlas, mx, shift, false);
|
||||
true
|
||||
}
|
||||
/// Copy the live player and world state into the `Level` that gets saved.
|
||||
pub(super) fn sync_level(&mut self) {
|
||||
// Level.write saves the game rules (game_rules.dat) with the rest.
|
||||
@@ -11645,8 +11718,17 @@ impl ApplicationHandler for Game<'_> {
|
||||
// The hovered slot took the key (`ignoreTextInput`).
|
||||
} else if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.creative_search_shortcut(shortcut);
|
||||
} else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) {
|
||||
self.creative_search_backspace(self.has_control_down());
|
||||
} else if let PhysicalKey::Code(key) = event.physical_key {
|
||||
// EditBox.keyPressed: arrows, Home, End, Delete and Backspace.
|
||||
let before = self.creative_screen.search.value.clone();
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
if self.creative_screen.search.key(key, ctrl, shift) {
|
||||
if self.creative_screen.search.value != before {
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
} else {
|
||||
self.creative_search_edit(event.text.as_deref());
|
||||
}
|
||||
} else {
|
||||
self.creative_search_edit(event.text.as_deref());
|
||||
}
|
||||
@@ -11667,6 +11749,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
// The anvil rename box takes every key but Escape (AnvilScreen.keyPressed).
|
||||
if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.station_shortcut(shortcut);
|
||||
} else if let PhysicalKey::Code(key) = event.physical_key
|
||||
&& self.station_key(key)
|
||||
{
|
||||
} else {
|
||||
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
|
||||
self.station_text(event.text.as_deref(), backspace);
|
||||
@@ -12283,6 +12368,7 @@ impl<'a> Game<'a> {
|
||||
map_frame_model: prepared.map_frame_model,
|
||||
glow_map_frame_model: prepared.glow_map_frame_model,
|
||||
armor_stands: Vec::new(),
|
||||
cushions: Vec::new(),
|
||||
others: Vec::new(),
|
||||
armor_stand_assets: prepared.armor_stand_assets,
|
||||
waypoint_styles: prepared.waypoint_styles,
|
||||
@@ -12292,6 +12378,7 @@ impl<'a> Game<'a> {
|
||||
boats: Vec::new(),
|
||||
boat_models: prepared.boat_models,
|
||||
crystal_model: prepared.crystal_model,
|
||||
cushion_model: prepared.cushion_model,
|
||||
carts: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
@@ -12430,6 +12517,7 @@ struct GameCpuResources {
|
||||
boat_models: boats::BoatModels,
|
||||
cart_models: minecarts::CartModels,
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
cushion_model: Option<horizon_client::mobs::Part>,
|
||||
dragon_model: Option<horizon_client::mobs::Part>,
|
||||
arrow_model: Option<horizon_client::mobs::Part>,
|
||||
trident_model: Option<horizon_client::mobs::Part>,
|
||||
@@ -12450,6 +12538,7 @@ impl GameCpuResources {
|
||||
boat_models: boats::BoatModels::load(document.as_ref()),
|
||||
cart_models: minecarts::CartModels::load(document.as_ref()),
|
||||
crystal_model: model("minecraft:end_crystal#main"),
|
||||
cushion_model: model("minecraft:cushion#main"),
|
||||
dragon_model: model("minecraft:ender_dragon#main"),
|
||||
arrow_model: model("minecraft:arrow#main"),
|
||||
trident_model: model("minecraft:trident#main"),
|
||||
@@ -13805,7 +13894,7 @@ mod startup_cpu_tests {
|
||||
r#"{
|
||||
"minecraft:end_crystal#main": {}, "minecraft:ender_dragon#main": {},
|
||||
"minecraft:arrow#main": {}, "minecraft:trident#main": {},
|
||||
"minecraft:armor_stand#main": {}
|
||||
"minecraft:armor_stand#main": {}, "minecraft:cushion#main": {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -13825,6 +13914,7 @@ mod startup_cpu_tests {
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(prepared.crystal_model.is_some());
|
||||
assert!(prepared.cushion_model.is_some());
|
||||
assert!(prepared.dragon_model.is_some());
|
||||
assert!(prepared.arrow_model.is_some());
|
||||
assert!(prepared.trident_model.is_some());
|
||||
|
||||
@@ -515,8 +515,7 @@ impl Game<'_> {
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
} else if let Some(stack) = stack_tip {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
let text = self.translate(&key, &key);
|
||||
self.draw_tooltip_lines(
|
||||
|
||||
@@ -56,6 +56,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
|
||||
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
|
||||
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
|
||||
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +93,10 @@ impl Game<'_> {
|
||||
| "minecraft:vault"
|
||||
| "minecraft:beehive"
|
||||
| "minecraft:bee_nest"
|
||||
| "minecraft:brewing_stand" => Ticking::Yes,
|
||||
| "minecraft:brewing_stand"
|
||||
// Lit, cookTick; unlit, cooldownTick.
|
||||
| "minecraft:campfire"
|
||||
| "minecraft:soul_campfire" => Ticking::Yes,
|
||||
"minecraft:jukebox" => {
|
||||
let record = registry
|
||||
.state(state)
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(super) struct Boat {
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
@@ -587,6 +587,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// The player's tick while riding: steer, tick the boat, sit in it.
|
||||
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
|
||||
if self.tick_cushion_riding(keys) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
|
||||
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
|
||||
return Ok(());
|
||||
|
||||
@@ -17,7 +17,6 @@ use horizon_client::{
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -27,8 +26,24 @@ const BLACK: u32 = 0xFF00_0000;
|
||||
const PAGE_CHARS: usize = 1024;
|
||||
const MAX_PAGES: usize = 100;
|
||||
const LINES: usize = 14;
|
||||
/// `BookSignScreen`'s title box.
|
||||
const BOOK_TITLE: horizon_client::clipboard::FieldId = ("book.title", 0);
|
||||
|
||||
fn book_title_box() -> ui::EditBox {
|
||||
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
|
||||
edit.bordered = false;
|
||||
edit.max_length = 15;
|
||||
edit.text_color = BLACK;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
fn book_page_box() -> ui::EditBox {
|
||||
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
|
||||
edit.bordered = false;
|
||||
edit.multiline = true;
|
||||
edit.max_length = PAGE_CHARS;
|
||||
edit.text_color = BLACK;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
|
||||
pub(super) enum BookScreen {
|
||||
Edit {
|
||||
@@ -38,7 +53,10 @@ pub(super) enum BookScreen {
|
||||
page: usize,
|
||||
signing: bool,
|
||||
title: String,
|
||||
opened: Instant,
|
||||
/// BookSignScreen's title box (`EditBox` of 15).
|
||||
title_edit: ui::EditBox,
|
||||
/// BookEditScreen's page (`MultiLineEditBox`).
|
||||
page_edit: ui::EditBox,
|
||||
},
|
||||
View {
|
||||
pages: Vec<String>,
|
||||
@@ -84,13 +102,16 @@ impl Game<'_> {
|
||||
if pages.is_empty() {
|
||||
pages.push(String::new());
|
||||
}
|
||||
let mut page_edit = book_page_box();
|
||||
page_edit.set_value(&pages[0]);
|
||||
BookScreen::Edit {
|
||||
slot: self.held_slot(),
|
||||
pages,
|
||||
page: 0,
|
||||
signing: false,
|
||||
title: String::new(),
|
||||
opened: Instant::now(),
|
||||
title_edit: book_title_box(),
|
||||
page_edit,
|
||||
}
|
||||
}
|
||||
"minecraft:written_book" => BookScreen::View {
|
||||
@@ -239,34 +260,24 @@ impl Game<'_> {
|
||||
Some(KeyCode::PageDown) => self.turn_page(true),
|
||||
_ => {}
|
||||
},
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) => match key {
|
||||
Some(BookScreen::Edit { signing: true, .. }) => match key {
|
||||
Some(KeyCode::Escape) => {
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = false;
|
||||
}
|
||||
}
|
||||
Some(KeyCode::Backspace) => {
|
||||
if !self.text_selection.take(BOOK_TITLE, title) {
|
||||
title.pop();
|
||||
}
|
||||
}
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
|
||||
if !title.trim().is_empty() {
|
||||
let blank = self.book.as_ref().is_some_and(
|
||||
|b| matches!(b, BookScreen::Edit { title, .. } if title.trim().is_empty()),
|
||||
);
|
||||
if !blank {
|
||||
self.save_book(true);
|
||||
}
|
||||
}
|
||||
Some(key) if self.book_title_key(key) => {}
|
||||
_ => {
|
||||
if let Some(typed) = text {
|
||||
self.text_selection.take(BOOK_TITLE, title);
|
||||
for c in typed.chars().filter(|c| !c.is_control()) {
|
||||
if title.chars().count() < 15 {
|
||||
title.push(c);
|
||||
}
|
||||
}
|
||||
self.book_title_insert(typed);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -274,24 +285,13 @@ impl Game<'_> {
|
||||
Some(KeyCode::Escape) => self.save_book(false),
|
||||
Some(KeyCode::PageUp) => self.turn_page(false),
|
||||
Some(KeyCode::PageDown) => self.turn_page(true),
|
||||
Some(KeyCode::Backspace) => {
|
||||
if !self.take_page_selection() {
|
||||
self.edit_page(|p| {
|
||||
p.pop();
|
||||
})
|
||||
}
|
||||
}
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
|
||||
self.take_page_selection();
|
||||
self.type_into_page("\n")
|
||||
}
|
||||
Some(KeyCode::ArrowUp) => self.book_move_line(-1),
|
||||
Some(KeyCode::ArrowDown) => self.book_move_line(1),
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"),
|
||||
Some(key) if self.book_page_key(key) => {}
|
||||
_ => {
|
||||
if let Some(typed) = text {
|
||||
let typed: String = typed.chars().filter(|c| !c.is_control()).collect();
|
||||
if !typed.is_empty() {
|
||||
self.take_page_selection();
|
||||
}
|
||||
self.type_into_page(&typed);
|
||||
self.page_insert(typed);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -302,99 +302,258 @@ impl Game<'_> {
|
||||
pub(super) fn book_type(&mut self, text: &str) {
|
||||
let text = text.replace("\\n", "\n");
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) => {
|
||||
for c in text.chars().filter(|c| !c.is_control()) {
|
||||
if title.chars().count() < 15 {
|
||||
title.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(BookScreen::Edit { .. }) => self.type_into_page(&text),
|
||||
Some(BookScreen::Edit { signing: true, .. }) => self.book_title_insert(&text),
|
||||
Some(BookScreen::Edit { .. }) => self.page_insert(&text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Before typing on a page: Select All's selection is cleared (true).
|
||||
fn take_page_selection(&mut self) -> bool {
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => self
|
||||
.text_selection
|
||||
.take(("book.page", *page), &mut pages[*page]),
|
||||
_ => false,
|
||||
/// `BookSignScreen.keyPressed` on the title box.
|
||||
fn book_title_key(&mut self, key: KeyCode) -> bool {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !title_edit.key(key, ctrl, shift) {
|
||||
return false;
|
||||
}
|
||||
title.clone_from(&title_edit.value);
|
||||
true
|
||||
}
|
||||
/// Ctrl+A/C/V/X: the title box (an `EditBox` of 15) while signing, else
|
||||
/// the page (`MultilineTextField.insertText`: line breaks kept, cut to
|
||||
/// the 1024 characters left, rejected when it overflows the 14 lines).
|
||||
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
let font = &self.gui.atlas.font;
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) => {
|
||||
Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
BOOK_TITLE,
|
||||
title,
|
||||
shortcut,
|
||||
15,
|
||||
);
|
||||
}
|
||||
Some(BookScreen::Edit { pages, page, .. }) => {
|
||||
// truncateInsertionText counts the whole value, selection included.
|
||||
let room = PAGE_CHARS.saturating_sub(pages[*page].chars().count());
|
||||
self.text_selection.apply(
|
||||
("book.page", *page),
|
||||
&mut pages[*page],
|
||||
shortcut,
|
||||
&mut self.clipboard,
|
||||
|value, pasted| {
|
||||
let text: String = horizon_client::clipboard::filter_text(pasted, true)
|
||||
.chars()
|
||||
.take(room)
|
||||
.collect();
|
||||
let next = format!("{value}{text}");
|
||||
font.split(&Text::literal(&next), 114.0).len() <= LINES && {
|
||||
*value = next;
|
||||
true
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn edit_page(&mut self, f: impl FnOnce(&mut String)) {
|
||||
if let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_mut() {
|
||||
f(&mut pages[*page]);
|
||||
}
|
||||
}
|
||||
/// MultiLineEditBox: characters that keep the page within 14 lines and 1024 characters.
|
||||
fn type_into_page(&mut self, typed: &str) {
|
||||
let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_ref() else {
|
||||
fn book_title_insert(&mut self, text: &str) {
|
||||
let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut text = pages[*page].clone();
|
||||
for c in typed.chars() {
|
||||
let mut next = text.clone();
|
||||
next.push(c);
|
||||
if next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES {
|
||||
text = next;
|
||||
title_edit.insert_text(text);
|
||||
title.clone_from(&title_edit.value);
|
||||
}
|
||||
/// `BookEditScreen.keyPressed` on the page, apart from the line moves.
|
||||
fn book_page_key(&mut self, key: KeyCode) -> bool {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
let before = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => Some((
|
||||
page_edit.value.clone(),
|
||||
page_edit.cursor,
|
||||
page_edit.highlight,
|
||||
)),
|
||||
_ => return false,
|
||||
};
|
||||
let handled = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.key(key, ctrl, shift)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if handled {
|
||||
self.page_keep(before);
|
||||
}
|
||||
handled
|
||||
}
|
||||
/// One character at a time, so a page stops at the last character that fits.
|
||||
fn page_insert(&mut self, text: &str) {
|
||||
for c in text.chars() {
|
||||
self.page_change(|edit| edit.insert_text(&c.to_string()));
|
||||
}
|
||||
}
|
||||
/// Ctrl+A/C/V/X: the title box while signing, else the page.
|
||||
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
let signing = matches!(self.book, Some(BookScreen::Edit { signing: true, .. }));
|
||||
if signing {
|
||||
if let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
{
|
||||
title_edit.shortcut(shortcut, &mut self.clipboard);
|
||||
title.clone_from(&title_edit.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let before = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => Some((
|
||||
page_edit.value.clone(),
|
||||
page_edit.cursor,
|
||||
page_edit.highlight,
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.shortcut(shortcut, &mut self.clipboard);
|
||||
}
|
||||
self.page_keep(before);
|
||||
}
|
||||
/// Edit the page box, then keep it only within 14 lines and 1024 characters.
|
||||
fn page_change(&mut self, f: impl FnOnce(&mut ui::EditBox)) -> bool {
|
||||
let before = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => Some((
|
||||
page_edit.value.clone(),
|
||||
page_edit.cursor,
|
||||
page_edit.highlight,
|
||||
)),
|
||||
_ => return false,
|
||||
};
|
||||
let ran = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
f(page_edit);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
ran && self.page_keep(before)
|
||||
}
|
||||
/// Write the page, or put the previous text back when it no longer fits.
|
||||
fn page_keep(&mut self, before: Option<(String, usize, usize)>) -> bool {
|
||||
let next = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => page_edit.value.clone(),
|
||||
_ => return false,
|
||||
};
|
||||
let ok = next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES;
|
||||
let Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if ok {
|
||||
pages[*page].clone_from(&page_edit.value);
|
||||
} else if let Some((value, cursor, highlight)) = before {
|
||||
page_edit.value = value;
|
||||
page_edit.cursor = cursor;
|
||||
page_edit.highlight = highlight;
|
||||
}
|
||||
ok
|
||||
}
|
||||
/// ArrowUp and ArrowDown move a visual line, keeping the column.
|
||||
fn book_move_line(&mut self, dir: isize) {
|
||||
let shift = self.has_shift_down();
|
||||
let (text, cursor) = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) => (pages[*page].clone(), page_edit.cursor),
|
||||
_ => return,
|
||||
};
|
||||
let rows = self.page_rows(&text);
|
||||
let (x, line) = self.caret_on(&text, &rows, cursor);
|
||||
let next = (line as isize + dir).clamp(0, rows.len() as isize - 1) as usize;
|
||||
let (start, end) = rows[next];
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let slice: String = chars[start..end].iter().collect();
|
||||
let pos = start + self.gui.atlas.font.fit(&slice, x);
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// Wrapped rows of a page as character ranges, the same breaks as `font.split`.
|
||||
fn page_rows(&self, page: &str) -> Vec<(usize, usize)> {
|
||||
let font = &self.gui.atlas.font;
|
||||
let chars: Vec<char> = page.chars().collect();
|
||||
let mut rows = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < chars.len() {
|
||||
let mut end = chars.len();
|
||||
let mut next = chars.len();
|
||||
let mut last_space = None;
|
||||
for i in start..chars.len() {
|
||||
if chars[i] == '\n' {
|
||||
end = i;
|
||||
next = i + 1;
|
||||
break;
|
||||
}
|
||||
if chars[i] == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
let slice: String = chars[start..=i].iter().collect();
|
||||
if font.width(&slice) > 114.0 {
|
||||
match last_space {
|
||||
Some(s) if s > start => {
|
||||
end = s;
|
||||
next = s + 1;
|
||||
}
|
||||
_ => {
|
||||
end = i.max(start + 1);
|
||||
next = end;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
rows.push((start, end));
|
||||
start = next;
|
||||
if start == chars.len() && chars.last() == Some(&'\n') {
|
||||
rows.push((chars.len(), chars.len()));
|
||||
}
|
||||
}
|
||||
self.edit_page(|p| *p = text);
|
||||
if rows.is_empty() {
|
||||
rows.push((0, 0));
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// GUI x of the caret and the row it sits on.
|
||||
fn caret_on(&self, page: &str, rows: &[(usize, usize)], cursor: usize) -> (f32, usize) {
|
||||
let chars: Vec<char> = page.chars().collect();
|
||||
for (i, &(start, end)) in rows.iter().enumerate() {
|
||||
if cursor <= end || i + 1 == rows.len() {
|
||||
let take = cursor.saturating_sub(start).min(end.saturating_sub(start));
|
||||
let prefix: String = chars.iter().skip(start).take(take).collect();
|
||||
return (self.gui.atlas.font.width(&prefix), i);
|
||||
}
|
||||
}
|
||||
(0.0, 0)
|
||||
}
|
||||
fn page_index_at(&self, page: &str, local_x: f32, local_y: f32) -> usize {
|
||||
let rows = self.page_rows(page);
|
||||
let line = (local_y / 9.0).floor().max(0.0) as usize;
|
||||
let line = line.min(rows.len().saturating_sub(1));
|
||||
let (start, end) = rows[line];
|
||||
let chars: Vec<char> = page.chars().collect();
|
||||
let slice: String = chars[start..end].iter().collect();
|
||||
start + self.gui.atlas.font.fit(&slice, local_x.max(0.0))
|
||||
}
|
||||
/// A click on the centred title (`EditBox.onClick`).
|
||||
fn book_title_click(&mut self, field_x: f32, mx: f32) {
|
||||
let shift = self.has_shift_down();
|
||||
let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let tx = field_x + ((114.0 - self.gui.atlas.font.width(title)) / 2.0).floor();
|
||||
let rel = (mx - tx).max(0.0);
|
||||
let pos = self.gui.atlas.font.fit(title, rel);
|
||||
title_edit.set_cursor(pos, shift);
|
||||
}
|
||||
/// A click on the open page.
|
||||
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
|
||||
let shift = self.has_shift_down();
|
||||
let text = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
|
||||
_ => return,
|
||||
};
|
||||
let pos = self.page_index_at(&text, mx - origin_x, my - origin_y);
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// The page buttons (`pageForward` adds a page while editing).
|
||||
fn turn_page(&mut self, forward: bool) {
|
||||
let mut lectern_page = None;
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => {
|
||||
Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) => {
|
||||
let before = *page;
|
||||
if forward {
|
||||
if *page + 1 >= pages.len() && pages.len() < MAX_PAGES {
|
||||
pages.push(String::new());
|
||||
@@ -405,6 +564,10 @@ impl Game<'_> {
|
||||
} else if *page > 0 {
|
||||
*page -= 1;
|
||||
}
|
||||
if *page != before {
|
||||
let text = pages[*page].clone();
|
||||
page_edit.set_value(&text);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
lectern: Some(key), ..
|
||||
@@ -764,6 +927,8 @@ impl Game<'_> {
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = false;
|
||||
}
|
||||
} else if inside(left + 36.0, 50.0, 114.0, 9.0) {
|
||||
self.book_title_click(left + 36.0, mx);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::Edit { page, .. }) => {
|
||||
@@ -780,6 +945,8 @@ impl Game<'_> {
|
||||
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
self.save_book(false);
|
||||
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
|
||||
self.book_page_click(left + 36.0, 32.0, mx, my);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
@@ -865,7 +1032,7 @@ impl Game<'_> {
|
||||
BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
opened,
|
||||
title_edit,
|
||||
..
|
||||
} => {
|
||||
let edit = tr("book.editTitle", "Enter Book Title:");
|
||||
@@ -881,19 +1048,35 @@ impl Game<'_> {
|
||||
// The title box: an unbordered, centred EditBox of 114 with
|
||||
// black text and no shadow; the value alone is centred, the
|
||||
// cursor follows it.
|
||||
let value = &title_edit.value;
|
||||
let x = left + 36.0;
|
||||
let tx = x + ((114.0 - HudBatch::text_width(atlas, title)) / 2.0).floor();
|
||||
horizon_client::ui::append_field(
|
||||
batch,
|
||||
atlas,
|
||||
title,
|
||||
(tx, 50.0, x + 114.0 - tx),
|
||||
(BLACK, false),
|
||||
15,
|
||||
self.text_selection.covers(BOOK_TITLE, title),
|
||||
true,
|
||||
opened.elapsed().as_millis(),
|
||||
);
|
||||
let tx = x + ((114.0 - HudBatch::text_width(atlas, value)) / 2.0).floor();
|
||||
batch.text(atlas, value, tx, 50.0, BLACK, false);
|
||||
if title_edit.cursor != title_edit.highlight {
|
||||
let (a, b) = (
|
||||
title_edit.cursor.min(title_edit.highlight),
|
||||
title_edit.cursor.max(title_edit.highlight),
|
||||
);
|
||||
let pre: String = value.chars().take(a).collect();
|
||||
let sel: String = value.chars().skip(a).take(b - a).collect();
|
||||
let sx = tx + HudBatch::text_width(atlas, &pre);
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(sx, 49.0, HudBatch::text_width(atlas, &sel), 11.0),
|
||||
true,
|
||||
);
|
||||
}
|
||||
if title_edit.cursor_visible() {
|
||||
let pre: String = value.chars().take(title_edit.cursor).collect();
|
||||
batch.fill(
|
||||
atlas,
|
||||
tx + HudBatch::text_width(atlas, &pre),
|
||||
49.0,
|
||||
1.0,
|
||||
9.0,
|
||||
BLACK,
|
||||
);
|
||||
}
|
||||
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
|
||||
let tw = HudBatch::text_width(atlas, &by);
|
||||
batch.text(
|
||||
@@ -941,12 +1124,12 @@ impl Game<'_> {
|
||||
BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
opened,
|
||||
page_edit,
|
||||
..
|
||||
} => {
|
||||
indicator(batch, *page, pages.len());
|
||||
let blink = (opened.elapsed().as_millis() / 300) % 2 == 0;
|
||||
let lines = self.book_lines(&pages[*page]);
|
||||
let text = &pages[*page];
|
||||
let lines = self.book_lines(text);
|
||||
for (i, line) in lines.iter().take(LINES).enumerate() {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
@@ -957,27 +1140,39 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
}
|
||||
if blink {
|
||||
// The cursor after the last character.
|
||||
let last = lines
|
||||
.last()
|
||||
.map(|l| atlas.font.width_text(l))
|
||||
.unwrap_or(0.0);
|
||||
let row = lines.len().saturating_sub(1) as f32
|
||||
+ if pages[*page].ends_with('\n') {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let x = if pages[*page].ends_with('\n') {
|
||||
0.0
|
||||
} else {
|
||||
last
|
||||
};
|
||||
let rows = self.page_rows(text);
|
||||
let (sel_a, sel_b) = (
|
||||
page_edit.cursor.min(page_edit.highlight),
|
||||
page_edit.cursor.max(page_edit.highlight),
|
||||
);
|
||||
if sel_a != sel_b {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, &(start, end)) in rows.iter().enumerate().take(LINES) {
|
||||
let from = sel_a.max(start);
|
||||
let to = sel_b.min(end);
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
let pre: String = chars[start..from].iter().collect();
|
||||
let sel: String = chars[from..to].iter().collect();
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(
|
||||
left + 36.0 + atlas.font.width(&pre),
|
||||
top + 29.0 + i as f32 * 9.0,
|
||||
atlas.font.width(&sel),
|
||||
9.0,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
if page_edit.cursor_visible() {
|
||||
let (x, row) = self.caret_on(text, &rows, page_edit.cursor);
|
||||
batch.fill(
|
||||
atlas,
|
||||
left + 36.0 + x,
|
||||
top + 29.0 + row * 9.0,
|
||||
top + 29.0 + row as f32 * 9.0,
|
||||
1.0,
|
||||
9.0,
|
||||
BLACK,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Campfires' cooking: `CampfireBlock.useItemOn` (`CampfireBlockEntity.placeFood`)
|
||||
//! and the block entity's `cookTick` (lit) and `cooldownTick` (unlit). The
|
||||
//! items drawn on the fire are `campfire_geometry`'s; a server's clients get
|
||||
//! them through the block entity's update tag (`lan::chunks::entity_tag`).
|
||||
use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
|
||||
items::ItemStack, mesher::Vertex, mobs::Who,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
/// The `campfire_cooking` recipe kind (`RecipeType.CAMPFIRE_COOKING`).
|
||||
const KIND: &str = "minecraft:campfire_cooking";
|
||||
|
||||
fn is_campfire(name: &str) -> bool {
|
||||
name == "minecraft:campfire" || name == "minecraft:soul_campfire"
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CampfireBlock.useItemOn` with a held item that is a campfire recipe's
|
||||
/// input (`RecipePropertySet.CAMPFIRE_INPUT`): on a server level the food
|
||||
/// goes into the first free slot (`placeFood`), and the use is consumed
|
||||
/// whether or not there was room. A client of a server leaves the placing
|
||||
/// to the server. Whether the food went in; None when this isn't a
|
||||
/// campfire use, so the item's own use runs.
|
||||
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !is_campfire(&self.res.facts().get(state).name) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A client of a server predicts the placing; the server decides.
|
||||
if self.remote.is_some() {
|
||||
return Ok(Some(true));
|
||||
}
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let placed = match self.world.block_entities.get_mut(&key) {
|
||||
Some(BlockEntity::Campfire(c)) => {
|
||||
let mut one = held.clone();
|
||||
c.place_food(&mut one, time)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if placed {
|
||||
// ItemStack.consumeAndReturn: creative keeps the item.
|
||||
self.consume_held();
|
||||
self.stats.custom("interact_with_campfire", 1);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
Some(Who::Player),
|
||||
Some(state),
|
||||
);
|
||||
self.mark_block_entity_updated(key);
|
||||
}
|
||||
Ok(Some(placed))
|
||||
}
|
||||
|
||||
/// `CampfireBlockEntity.cookTick` of a lit campfire, `cooldownTick` of an
|
||||
/// unlit one. A finished item's result (the recipe's, or the item itself
|
||||
/// when no recipe takes it any more) drops at the block's corner
|
||||
/// (`Containers.dropItemStack` at its integer position), then the clients
|
||||
/// see the slot empty and a `block_change` goes out.
|
||||
pub(super) fn tick_campfire(&mut self, pos: Pos) -> Result<()> {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let lit = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("lit"))
|
||||
.is_some_and(|v| v == "true");
|
||||
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
// BlockEntity.setChanged: a timer that moved is saved with the
|
||||
// chunk (no update to the clients, who don't see the timers).
|
||||
let changed = if lit {
|
||||
c.items.iter().any(Option::is_some)
|
||||
} else {
|
||||
c.cooldown_tick()
|
||||
};
|
||||
if changed {
|
||||
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
|
||||
}
|
||||
if !lit {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let done = c.cook_tick();
|
||||
for (_, item) in done {
|
||||
let result = self
|
||||
.res
|
||||
.recipes()
|
||||
.cook(KIND, &item)
|
||||
.map_or_else(|| item.clone(), |r| r.result.clone());
|
||||
self.campfire_drop(pos, result);
|
||||
self.mark_block_entity_updated(pos);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
[pos.0, pos.1, pos.2],
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
|
||||
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
|
||||
/// (90° about X), at a corner and three eighths of its size, drawn as a
|
||||
/// FIXED item seeded by the block's position plus the slot.
|
||||
pub(super) fn campfire_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Campfire(c) = entity else {
|
||||
continue;
|
||||
};
|
||||
if c.items.iter().all(Option::is_none) {
|
||||
continue;
|
||||
}
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
// Direction.get2DDataValue: south 0, west 1, north 2, east 3.
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
let facing = match self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing"))
|
||||
.map(String::as_str)
|
||||
{
|
||||
Some("west") => 1,
|
||||
Some("north") => 2,
|
||||
Some("east") => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let light = vertex_light(self.world.light(key.0, key.1, key.2));
|
||||
let packed = BlockPos::new(key.0, key.1, key.2).pack();
|
||||
for (i, stack) in c.items.iter().enumerate() {
|
||||
let Some(stack) = stack else {
|
||||
continue;
|
||||
};
|
||||
let seed = (packed as i32).wrapping_add(i as i32);
|
||||
let item = self.resolve_item(stack, DisplayContext::Fixed, None, seed);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Direction.toYRot: 90° per 2D step from south.
|
||||
let yaw = -(((i + facing) % 4) as f32 * 90.0);
|
||||
let pose = glam::Mat4::from_translation(
|
||||
(at - camera).as_vec3() + glam::Vec3::new(0.5, 0.449_218_75, 0.5),
|
||||
) * glam::Mat4::from_rotation_y(yaw.to_radians())
|
||||
* glam::Mat4::from_rotation_x(90f32.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(-0.3125, -0.3125, 0.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(0.375));
|
||||
self.submit_item_world(&item, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
|
||||
/// integer position.
|
||||
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
for piece in
|
||||
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
|
||||
{
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
count: piece.count,
|
||||
..stack.clone()
|
||||
},
|
||||
piece.pos,
|
||||
piece.motion,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +623,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(command) = message.strip_prefix('/') {
|
||||
self.run_command(command);
|
||||
// A client mod's command first, then a server mod's, as on a server.
|
||||
if !self.mod_command(command) && !self.local_server_mod_command(command) {
|
||||
self.run_command(command);
|
||||
}
|
||||
} else {
|
||||
let line = Text::translate(
|
||||
"chat.type.text",
|
||||
@@ -643,6 +646,7 @@ impl Game<'_> {
|
||||
self.mods_chat(&line);
|
||||
}
|
||||
self.lan_host_chat(message);
|
||||
self.mod_player_chat(super::server_mods::Who::Local, message);
|
||||
}
|
||||
}
|
||||
pub(super) fn chat_scroll(&mut self, amount: f32) {
|
||||
|
||||
@@ -63,8 +63,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Typing or a backspace in a field of the title screens: in the server
|
||||
/// screens' append-only fields Select All's selection is replaced first.
|
||||
/// Typing or a backspace in a title-screen field. A field that still only
|
||||
/// appends replaces a Select All selection before the edit.
|
||||
pub(super) fn title_edit(&mut self, typed: Option<&str>, backspace: bool) {
|
||||
let Some(title) = self.title.as_mut() else {
|
||||
return;
|
||||
@@ -86,42 +86,31 @@ impl Game<'_> {
|
||||
}
|
||||
/// Shortcuts in the creative inventory's search box (an `EditBox` of 50).
|
||||
pub(super) fn creative_search_shortcut(&mut self, shortcut: EditShortcut) {
|
||||
if Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
CREATIVE_SEARCH,
|
||||
&mut self.creative_screen.search,
|
||||
shortcut,
|
||||
CREATIVE_SEARCH_CHARS,
|
||||
) {
|
||||
if self
|
||||
.creative_screen
|
||||
.search
|
||||
.shortcut(shortcut, &mut self.clipboard)
|
||||
{
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
/// A backspace in the creative search box: with Ctrl, the whole word
|
||||
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
|
||||
/// A Select All selection is what it deletes, as in `EditBox.keyPressed`.
|
||||
/// A selection is what it deletes, as in `EditBox.keyPressed`.
|
||||
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
if !self.text_selection.take(CREATIVE_SEARCH, search) {
|
||||
if ctrl {
|
||||
horizon_client::ui::delete_words(search, -1);
|
||||
} else {
|
||||
search.pop();
|
||||
}
|
||||
}
|
||||
self.creative_screen
|
||||
.search
|
||||
.key(KeyCode::Backspace, ctrl, false);
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
/// Typing in the creative search box.
|
||||
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
self.text_selection.take(CREATIVE_SEARCH, search);
|
||||
if let Some(text) = typed {
|
||||
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
|
||||
self.creative_screen.search.insert_text(text);
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen`'s search box: `setMaxLength(50)`.
|
||||
pub(super) const CREATIVE_SEARCH_CHARS: usize = 50;
|
||||
pub(super) const CREATIVE_SEARCH: horizon_client::clipboard::FieldId = ("creative.search", 0);
|
||||
@@ -52,11 +52,16 @@ impl Game<'_> {
|
||||
/// Remember who deals the next `damage_type` damage to the player.
|
||||
pub(super) fn note_attacker(&mut self, damage_type: &str, mob: u32) {
|
||||
if let Some(name) = self.mob_display_name(mob) {
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (mob, name)));
|
||||
self.note_named_attacker(damage_type, mob, name);
|
||||
}
|
||||
}
|
||||
/// `note_attacker` for an entity that is not one of `self.mobs` (the
|
||||
/// ender dragon).
|
||||
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name)));
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
|
||||
@@ -85,16 +85,16 @@ fn format_double(v: f64) -> String {
|
||||
|
||||
impl Game<'_> {
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) {
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
if self.mod_command(command) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
let d = vanilla::game_dispatcher();
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let result = match parse.executable() {
|
||||
Err(e) => {
|
||||
self.syntax_failure(&e, command);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
Ok((ctx, _)) => {
|
||||
let ctx = ctx.clone();
|
||||
@@ -103,6 +103,7 @@ impl Game<'_> {
|
||||
self.run_chain(&root, &ctx)
|
||||
}
|
||||
};
|
||||
let ok = result.is_ok();
|
||||
if let Err(e) = result {
|
||||
if e.input.is_some() {
|
||||
self.syntax_failure(&e, command);
|
||||
@@ -114,6 +115,20 @@ impl Game<'_> {
|
||||
if self.player.is_some() {
|
||||
self.check_inventory_trigger();
|
||||
}
|
||||
ok
|
||||
}
|
||||
/// `run_command` with its output suppressed
|
||||
/// (`CommandSourceStack.withSuppressedOutput`): the player's own source,
|
||||
/// rights and checks, no feedback and no failure in the chat.
|
||||
pub(super) fn run_command_quietly(&mut self, command: &str) -> bool {
|
||||
let quiet = execute::Source {
|
||||
silent: true,
|
||||
..self.player_source()
|
||||
};
|
||||
let outer = self.command_source.replace(quiet);
|
||||
let ok = self.run_command(command);
|
||||
self.command_source = outer;
|
||||
ok
|
||||
}
|
||||
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
|
||||
self.command_failure(e.message.clone());
|
||||
|
||||
@@ -50,9 +50,9 @@ pub(super) struct Creatures {
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
}
|
||||
struct ProjWorld<'a, 'w> {
|
||||
w: &'a MobWorld<'w>,
|
||||
targets: Vec<(Who, Aabb)>,
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
}
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
@@ -373,6 +373,7 @@ impl Game<'_> {
|
||||
// Drawn seated (Entity.isPassenger); set apart because the
|
||||
// json! above is at the macro recursion limit.
|
||||
state["passenger"] = m.extra.passenger.into();
|
||||
state["guardian_beam"] = serde_json::json!(horizon_client::mobs::guardian::beam(m, 0.0));
|
||||
state
|
||||
})
|
||||
.collect();
|
||||
@@ -1065,7 +1066,10 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::BreakBlock { pos, drop } => {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.destroy_particles(pos, old);
|
||||
// Block.spawnDestroyParticles: fire breaks leave nothing.
|
||||
if !self.res.facts().get(old).has_class("BaseFireBlock") {
|
||||
self.destroy_particles(pos, old);
|
||||
}
|
||||
if drop {
|
||||
let drops = self.res.items().block_drops_in(
|
||||
self.pack.assets.registry,
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! Cushions: `CushionItem.useOn` placement on the top face of a block,
|
||||
//! `Cushion.canBePlacedAt`/`survives`, `Cushion.interact` (sit),
|
||||
//! `BlockAttachedEntity.hurtServer` (break, dropping the matching colour),
|
||||
//! `BlockAttachedEntity.tick` and `CushionRenderer`.
|
||||
//!
|
||||
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
|
||||
//! only an entity whose `Pos` is the click point's height. Placement uses
|
||||
//! outline bounds, collision shapes and suffocation predicates.
|
||||
use super::{Game, Hit, view_vector};
|
||||
use horizon_client::entities::{Cushion, dye_index};
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `Direction.fromYRot(yaw).toYRot()`: the nearest 90 degree step.
|
||||
pub(crate) fn snap_yaw(yaw: f32) -> f32 {
|
||||
(((f64::from(yaw) / 90.0 + 0.5).floor() as i64) & 3) as f32 * 90.0
|
||||
}
|
||||
|
||||
fn aabb(bounds: ([f64; 3], [f64; 3])) -> Aabb {
|
||||
Aabb::new(
|
||||
Vec3::new(bounds.0[0], bounds.0[1], bounds.0[2]),
|
||||
Vec3::new(bounds.1[0], bounds.1[1], bounds.1[2]),
|
||||
)
|
||||
}
|
||||
|
||||
fn overlaps(a: Aabb, b: Aabb) -> bool {
|
||||
a.intersects(b)
|
||||
}
|
||||
use horizon_client::cushion::blocks_in;
|
||||
|
||||
struct PlacementWorld<'a, 'r>(&'a Game<'r>);
|
||||
impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
|
||||
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
|
||||
let boxes = self.0.outline_boxes(p);
|
||||
let mut shape = *boxes.first()?;
|
||||
for b in &boxes[1..] {
|
||||
for k in 0..3 {
|
||||
shape[k] = shape[k].min(b[k]);
|
||||
shape[k + 3] = shape[k + 3].max(b[k + 3]);
|
||||
}
|
||||
}
|
||||
Some(Aabb::new(
|
||||
Vec3::new(shape[0], shape[1], shape[2]),
|
||||
Vec3::new(shape[3], shape[4], shape[5]),
|
||||
))
|
||||
}
|
||||
|
||||
fn collision_shapes(&self, bounds: Aabb) -> Vec<horizon_core::geometry::VoxelShape> {
|
||||
let binding = self.0.res.collision();
|
||||
let mut world = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.0.world,
|
||||
binding,
|
||||
subject: None,
|
||||
};
|
||||
// Include neighbour blocks whose collision shape can reach outside their cell.
|
||||
blocks_in(bounds.inflate(Vec3::new(1.0, 1.0, 1.0)))
|
||||
.filter_map(|p| {
|
||||
let state = self.0.world.block(p[0], p[1], p[2]);
|
||||
binding
|
||||
.collision_shape(&mut world, state, p)
|
||||
.ok()
|
||||
.map(|s| s.moved(Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn suffocating(&self, p: [i32; 3]) -> bool {
|
||||
let state = self.0.world.block(p[0], p[1], p[2]);
|
||||
let facts = self.0.res.facts().get(state);
|
||||
let name = facts.name.as_ref();
|
||||
// Blocks' explicit predicates, then the default causes_suffocation/full-shape rule.
|
||||
match name {
|
||||
"minecraft:dirt_path"
|
||||
| "minecraft:farmland"
|
||||
| "minecraft:mud"
|
||||
| "minecraft:soul_sand" => true,
|
||||
"minecraft:piston" | "minecraft:sticky_piston" => self
|
||||
.0
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.properties.get("extended").is_some_and(|p| p == "false")),
|
||||
"minecraft:mangrove_roots" | "minecraft:moving_piston" => false,
|
||||
_ if name.ends_with("_leaves")
|
||||
|| name.ends_with("glass")
|
||||
|| name.ends_with("copper_grate") =>
|
||||
{
|
||||
false
|
||||
}
|
||||
// ShulkerBoxBlock: suffocating while closed; a box seen open
|
||||
// here is rare enough to count as closed.
|
||||
_ if name.ends_with("shulker_box") => true,
|
||||
_ => {
|
||||
facts.full_collision
|
||||
&& self
|
||||
.0
|
||||
.res
|
||||
.items()
|
||||
.block_in_tag("minecraft:causes_suffocation", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The cushion under the crosshair, its distance and the hit point.
|
||||
pub(super) fn cushion_hit(&self) -> Option<(usize, f64, [f64; 3])> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, c) in self.cushions.iter().enumerate() {
|
||||
let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else {
|
||||
continue;
|
||||
};
|
||||
if best.is_none_or(|b| d < b.1) {
|
||||
best = Some((i, d, std::array::from_fn(|k| eye[k] + dir[k] * d)));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
pub(super) fn cushion_survives(&self, c: &Cushion) -> bool {
|
||||
horizon_client::cushion::survives(&PlacementWorld(self), aabb(c.bounds()))
|
||||
}
|
||||
|
||||
/// `Cushion.destroyIfInFire`: a fire-tagged block inside the deflated box.
|
||||
fn cushion_in_fire(&self, c: &Cushion) -> bool {
|
||||
let bounds = aabb(c.bounds()).next_deflated();
|
||||
blocks_in(bounds).any(|p| {
|
||||
let facts = self.res.facts().get(self.world.block(p[0], p[1], p[2]));
|
||||
self.res.items().block_in_tag("minecraft:fire", &facts.name)
|
||||
})
|
||||
}
|
||||
|
||||
/// `CushionItem.useOn`: on the top face only, spawning at the click's
|
||||
/// height above the clicked block (or the replaceable block itself).
|
||||
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some(default_color) = info.cushion_color.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let color_override = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:cushion/color"))
|
||||
.and_then(|tag| match tag {
|
||||
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
});
|
||||
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
|
||||
// CushionItem.recalculateContextForSpecialCollisionShapes: open containers
|
||||
// (cauldrons, hoppers and composters) use the collision ray's actual height.
|
||||
let mut adjusted = Hit {
|
||||
pos: hit.pos,
|
||||
face: hit.face,
|
||||
point: hit.point,
|
||||
};
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
if self.res.items().block_in_tag(
|
||||
"minecraft:cushion_uses_collision_shape",
|
||||
&self.res.facts().get(state).name,
|
||||
) && let Some(eye) = self.eye_position()
|
||||
{
|
||||
let start = Vec3::new(eye[0], eye[1], eye[2]);
|
||||
let point = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
|
||||
let end = point.add_vector(point.subtract(start).normalize().scale(0.001));
|
||||
let binding = self.res.collision();
|
||||
let mut world = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding,
|
||||
subject: None,
|
||||
};
|
||||
if let Ok(shape) = binding.collision_shape(&mut world, state, hit.pos)
|
||||
&& let Some(result) = shape.clip(
|
||||
start,
|
||||
end,
|
||||
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
)
|
||||
{
|
||||
adjusted.face = result.direction.normal();
|
||||
adjusted.point = [result.location.x, result.location.y, result.location.z];
|
||||
}
|
||||
}
|
||||
let hit = &adjusted;
|
||||
if hit.face != [0, 1, 0] {
|
||||
return false;
|
||||
}
|
||||
// BlockPlaceContext: a replaceable clicked block is replaced.
|
||||
let clicked = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
let pos = if clicked.replaceable {
|
||||
hit.pos
|
||||
} else {
|
||||
std::array::from_fn(|i| hit.pos[i] + hit.face[i])
|
||||
};
|
||||
// `Vec3.atCenterOfWithY(clickedPos, clickLocation.y)`.
|
||||
let y = hit.point[1];
|
||||
let position = Vec3::new(f64::from(pos[0]) + 0.5, y, f64::from(pos[2]) + 0.5);
|
||||
let bounds = aabb((
|
||||
[position.x - 0.5, position.y, position.z - 0.5],
|
||||
[position.x + 0.5, position.y + 0.25, position.z + 0.5],
|
||||
));
|
||||
if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) {
|
||||
return false;
|
||||
}
|
||||
// `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`.
|
||||
if self
|
||||
.cushions
|
||||
.iter()
|
||||
.any(|c| overlaps(aabb(c.bounds()), bounds))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
// `BlockAttachedEntity.setPos`: `BlockPos.containing(position)`.
|
||||
let attached = [
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
];
|
||||
self.cushions.push(Cushion {
|
||||
id,
|
||||
position,
|
||||
pos: attached,
|
||||
yaw: snap_yaw(self.yaw),
|
||||
color,
|
||||
check_interval: 0,
|
||||
});
|
||||
self.world
|
||||
.modified
|
||||
.insert((attached[0] >> 4, attached[2] >> 4));
|
||||
// `destroyIfInFire`: placed into fire, it breaks at once.
|
||||
let placed = self.cushions.len() - 1;
|
||||
if self.cushion_in_fire(&self.cushions[placed]) {
|
||||
self.break_cushion(placed, false);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.place",
|
||||
Some([position.x, position.y, position.z]),
|
||||
0.75,
|
||||
0.8,
|
||||
);
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event(
|
||||
"minecraft:entity_place",
|
||||
[position.x, position.y, position.z],
|
||||
ctx,
|
||||
);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.interact`: sit down unless sneaking or already occupied.
|
||||
pub(super) fn interact_cushion(&mut self) -> bool {
|
||||
let Some((i, d, _)) = self.cushion_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
// isSecondaryUseActive.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return false;
|
||||
}
|
||||
let id = self.cushions[i].id;
|
||||
let occupied = self.riding == Some(id)
|
||||
|| self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(id)));
|
||||
// `Cushion.interact`: an occupied cushion (`isVehicle`) passes.
|
||||
if occupied {
|
||||
return false;
|
||||
}
|
||||
// `Player.startRiding` gives up the vehicle it already has; the
|
||||
// client never sends that click, so pass instead.
|
||||
if self.riding.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(id);
|
||||
let p = self.cushions[i].position;
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.sit",
|
||||
Some([p.x, p.y, p.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.hurtServer` from a player: killed, then the item dropped.
|
||||
pub(super) fn attack_cushion(&mut self) -> bool {
|
||||
let Some((i, d, _)) = self.cushion_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// `skipAttackInteraction` when `mayInteract` is false.
|
||||
if !self.may_build() {
|
||||
return true;
|
||||
}
|
||||
self.break_cushion(i, true);
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.dropItem`: the break sound and particles, then the coloured
|
||||
/// item unless drops are off or the breaker has infinite materials.
|
||||
pub(super) fn break_cushion(&mut self, index: usize, by_player: bool) {
|
||||
let c = self.cushions.remove(index);
|
||||
let p = c.position;
|
||||
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
|
||||
if self.riding == Some(c.id) {
|
||||
self.riding = None;
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.break",
|
||||
Some([p.x, p.y, p.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.cushion_break_particles(&c);
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
|
||||
return;
|
||||
}
|
||||
let stack = ItemStack {
|
||||
item: std::sync::Arc::from(c.item()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10);
|
||||
}
|
||||
|
||||
/// `Cushion.showBreakingParticles`: ten of the matching wool block.
|
||||
fn cushion_break_particles(&mut self, c: &Cushion) {
|
||||
let name = format!("minecraft:{}_wool", c.color_name());
|
||||
let Some(state) = self.pack.assets.registry.default_state(&name) else {
|
||||
return;
|
||||
};
|
||||
let at = [
|
||||
c.position.x.floor() as i32,
|
||||
c.position.y.floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
];
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
return;
|
||||
};
|
||||
let options = horizon_client::particles::ParticleOptions::block(
|
||||
horizon_client::particles::ParticleType::Block,
|
||||
sprite,
|
||||
tint,
|
||||
);
|
||||
let p = c.position;
|
||||
self.particles.send_particles(
|
||||
&options,
|
||||
[p.x, p.y + 0.666_666_7, p.z],
|
||||
[0.25, 0.0625, 0.25],
|
||||
0.05,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
|
||||
pub(super) fn tick_cushions(&mut self) {
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut i = 0;
|
||||
while i < self.cushions.len() {
|
||||
let p = self.cushions[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let previous = self.cushions[i].check_interval;
|
||||
self.cushions[i].check_interval += 1;
|
||||
if previous < 100 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
self.cushions[i].check_interval = 0;
|
||||
let c = self.cushions[i].clone();
|
||||
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
|
||||
self.break_cushion(i, false);
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
|
||||
let Some(c) = self
|
||||
.cushions
|
||||
.iter()
|
||||
.find(|c| Some(c.id) == self.riding)
|
||||
.cloned()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if keys.shift {
|
||||
let at = c.dismount();
|
||||
self.riding = None;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
// `Cushion.removePassenger`: the get-up sound for a rider that
|
||||
// left rather than one whose cushion was killed.
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.get_up",
|
||||
Some([c.position.x, c.position.y, c.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
let seat = c.seat();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `CushionRenderer`: the coloured model, rotated to the nearest
|
||||
/// direction, with its bottom on the cushion's position.
|
||||
pub(super) fn cushion_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(model) = self.cushion_model.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
for c in &self.cushions {
|
||||
let entity_start = out.len();
|
||||
let a = aabb(c.bounds());
|
||||
let (lo, hi) = (a.min_position(), a.max_position());
|
||||
let (lo, hi) = (
|
||||
glam::DVec3::new(lo.x, lo.y, lo.z),
|
||||
glam::DVec3::new(hi.x, hi.y, hi.z),
|
||||
);
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
let Some(uv) = sprites
|
||||
.get(&format!("entity/cushion/{}_cushion", c.color_name()))
|
||||
.map(|r| [r.u0, r.v0, r.u1, r.v1])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let p = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
|
||||
let base = glam::Mat4::from_translation(p.as_vec3())
|
||||
* glam::Mat4::from_rotation_y((180.0 - snap_yaw(c.yaw)).to_radians())
|
||||
* glam::Mat4::from_rotation_x(std::f32::consts::PI)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0));
|
||||
// `EntityRenderer.getPackedLightCoords` probes at the eye height.
|
||||
let l = self.world.light(
|
||||
c.position.x.floor() as i32,
|
||||
(c.position.y + 0.2125).floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&Poses::new(model),
|
||||
uv,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
|
||||
@@ -1691,25 +1693,8 @@ impl Game<'_> {
|
||||
}
|
||||
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
|
||||
let p = d.position;
|
||||
d.nearest_crystal = self
|
||||
.crystals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
(c.position.x - p.x).abs() <= 32.0
|
||||
&& (c.position.y - p.y).abs() <= 32.0
|
||||
&& (c.position.z - p.z).abs() <= 32.0
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let da = (a.1.position.x - p.x).powi(2)
|
||||
+ (a.1.position.y - p.y).powi(2)
|
||||
+ (a.1.position.z - p.z).powi(2);
|
||||
let db = (b.1.position.x - p.x).powi(2)
|
||||
+ (b.1.position.y - p.y).powi(2)
|
||||
+ (b.1.position.z - p.z).powi(2);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
.map(|(i, _)| i);
|
||||
let nearest = self.nearest_crystal_to(p);
|
||||
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
d.yaw = wrap(d.yaw);
|
||||
@@ -1870,7 +1855,7 @@ impl Game<'_> {
|
||||
pl.velocity.z += zd / dd * 4.0;
|
||||
}
|
||||
if !sitting {
|
||||
g.hurt_player_by("minecraft:mob_attack", 5.0);
|
||||
g.dragon_hurts_player("minecraft:mob_attack", 5.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1879,7 +1864,7 @@ impl Game<'_> {
|
||||
for h in heads {
|
||||
if h.inflate(Vec3::new(1.0, 1.0, 1.0)).intersects(p.bounds) {
|
||||
self.as_fight_player(p.who, |g| {
|
||||
g.hurt_player_by("minecraft:mob_attack", 10.0);
|
||||
g.dragon_hurts_player("minecraft:mob_attack", 10.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2161,10 +2146,20 @@ impl Game<'_> {
|
||||
self.breath_clouds.append(&mut clouds);
|
||||
for (who, amount) in harmed {
|
||||
self.as_fight_player(who, |g| {
|
||||
g.hurt_player_by("minecraft:indirect_magic", amount);
|
||||
g.dragon_hurts_player("minecraft:indirect_magic", amount);
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Damage from the dragon, which is its causing entity
|
||||
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
|
||||
/// death message names it.
|
||||
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
|
||||
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
|
||||
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
|
||||
self.note_named_attacker(damage_type, id, name);
|
||||
}
|
||||
self.hurt_player_by(damage_type, amount);
|
||||
}
|
||||
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
|
||||
/// (or a server's packets) put on this client's overlay.
|
||||
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
|
||||
@@ -3132,6 +3127,204 @@ impl Game<'_> {
|
||||
let _ = self.activate_exit_portal();
|
||||
self.fight.dirty = true;
|
||||
}
|
||||
/// `EnderDragon.checkCrystals`' search: the nearest crystal within 32
|
||||
/// blocks on each axis.
|
||||
fn nearest_crystal_to(&self, p: Vec3) -> Option<usize> {
|
||||
self.crystals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
(c.position.x - p.x).abs() <= 32.0
|
||||
&& (c.position.y - p.y).abs() <= 32.0
|
||||
&& (c.position.z - p.z).abs() <= 32.0
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let da = (a.1.position.x - p.x).powi(2)
|
||||
+ (a.1.position.y - p.y).powi(2)
|
||||
+ (a.1.position.z - p.z).powi(2);
|
||||
let db = (b.1.position.x - p.x).powi(2)
|
||||
+ (b.1.position.y - p.y).powi(2)
|
||||
+ (b.1.position.z - p.z).powi(2);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
}
|
||||
|
||||
/// The dragon on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
/// and the client side of `EnderDragon.aiStep`): the server flies it and
|
||||
/// sends its moves, health and phase; the client flaps its wings, keeps
|
||||
/// its flight history and places its parts, so it can be drawn and hit.
|
||||
impl Game<'_> {
|
||||
/// `handleAddEntity` for `minecraft:ender_dragon`. Its constructor
|
||||
/// sets the full health and the hovering phase until the server's
|
||||
/// synched data says otherwise.
|
||||
pub(super) fn spawn_remote_dragon(&mut self, id: i32, position: [f64; 3], yaw: f32) {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let mut d = Dragon::new(at, yaw, i64::from(id));
|
||||
d.id = id as u32;
|
||||
d.phase = Phase::from_id(10);
|
||||
self.dragon = Some(d);
|
||||
self.place_dragon_parts();
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
for part in &mut d.parts {
|
||||
part.1 = part.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The remote dragon, if `id` is it.
|
||||
fn remote_dragon_mut(&mut self, id: i32) -> Option<&mut Dragon> {
|
||||
self.dragon.as_mut().filter(|d| d.id as i32 == id)
|
||||
}
|
||||
/// `handleSetEntityData`: LivingEntity.DATA_HEALTH_ID and
|
||||
/// EnderDragon.DATA_PHASE (`onSyncedDataUpdated` sets the phase).
|
||||
pub(super) fn remote_dragon_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let Some(d) = self.remote_dragon_mut(id) else {
|
||||
return;
|
||||
};
|
||||
for (index, _, value) in values {
|
||||
match (index, value) {
|
||||
(9, Value::Float(health)) => d.health = *health,
|
||||
(16, Value::Int(phase)) => d.set_phase(Phase::from_id(*phase)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `handleSetEntityMotion`: the velocity its wings flap by.
|
||||
pub(super) fn remote_dragon_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
if let Some(d) = self.remote_dragon_mut(id) {
|
||||
d.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
}
|
||||
}
|
||||
/// `handleHurtAnimation` and `handleDamageEvent`: the red flash.
|
||||
pub(super) fn remote_dragon_hurt(&mut self, id: i32) {
|
||||
if let Some(d) = self.remote_dragon_mut(id) {
|
||||
d.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
/// `handleRemoveEntities`, or a crystal removed: the healing beam
|
||||
/// keeps pointing at the same crystal.
|
||||
pub(super) fn remove_remote_dragon(&mut self, id: i32) {
|
||||
if self.remote_dragon_mut(id).is_some() {
|
||||
self.dragon = None;
|
||||
return;
|
||||
}
|
||||
let Some(index) = self.crystals.iter().position(|c| c.id as i32 == id) else {
|
||||
return;
|
||||
};
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
match d.nearest_crystal {
|
||||
Some(n) if n == index => d.nearest_crystal = None,
|
||||
Some(n) if n > index => d.nearest_crystal = Some(n - 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One client tick of the dragon. `lerp`: the step toward its last
|
||||
/// sent position and yaw (LivingEntity's interpolation), and the
|
||||
/// steps left including this one.
|
||||
pub(super) fn tick_remote_dragon(&mut self, lerp: Option<([f64; 3], Option<f32>, u32)>) {
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return;
|
||||
};
|
||||
d.previous = d.position;
|
||||
d.previous_yaw = d.yaw;
|
||||
d.old_flap = d.flap;
|
||||
d.ticks += 1;
|
||||
d.hurt_time = (d.hurt_time - 1).max(0);
|
||||
for part in &mut d.parts {
|
||||
part.1 = part.0;
|
||||
}
|
||||
if let Some((target, yaw, steps)) = lerp {
|
||||
let n = f64::from(steps.max(1));
|
||||
d.position = Vec3::new(
|
||||
d.position.x + (target[0] - d.position.x) / n,
|
||||
d.position.y + (target[1] - d.position.y) / n,
|
||||
d.position.z + (target[2] - d.position.z) / n,
|
||||
);
|
||||
if let Some(yaw) = yaw {
|
||||
d.yaw += wrap(yaw - d.yaw) / steps.max(1) as f32;
|
||||
}
|
||||
}
|
||||
if !d.alive() {
|
||||
// EnderDragon.tickDeath on a client: it rises, and bursts
|
||||
// from tick 180 to 200; the server removes it.
|
||||
d.death_time += 1;
|
||||
let (t, p) = (d.death_time, d.position);
|
||||
if (180..=200).contains(&t) {
|
||||
let r = &mut d.random;
|
||||
let o = [
|
||||
f64::from((r.next_float() - 0.5) * 8.0),
|
||||
f64::from((r.next_float() - 0.5) * 4.0),
|
||||
f64::from((r.next_float() - 0.5) * 8.0),
|
||||
];
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::ExplosionEmitter),
|
||||
[p.x + o[0], p.y + 2.0 + o[1], p.z + o[2]],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
d.position.y += 0.1;
|
||||
for part in &mut d.parts {
|
||||
part.0.y += 0.1;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// processFlappingMovement and onFlap: its wing beats, and their sound.
|
||||
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
|
||||
let v = d.velocity;
|
||||
let mut flap_speed = 0.2 / ((v.x.hypot(v.z) as f32) * 10.0 + 1.0);
|
||||
flap_speed *= 2f32.powf(v.y as f32);
|
||||
d.flap += if d.phase.sitting() { 0.1 } else { flap_speed };
|
||||
let flap_now = (d.flap * std::f32::consts::TAU).cos();
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
if flap_was <= -0.3 && flap_now >= -0.3 {
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.flap", Some(at), 5.0, pitch);
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
if !d.phase.sitting() {
|
||||
d.growl -= 1;
|
||||
if d.growl < 0 {
|
||||
d.growl = 200 + d.random.next_int_bound(200).unwrap_or(0);
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
|
||||
}
|
||||
}
|
||||
// checkCrystals' search runs on a client too (the healing beam).
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
|
||||
let p = d.position;
|
||||
let nearest = self.nearest_crystal_to(p);
|
||||
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
d.yaw = wrap(d.yaw);
|
||||
d.record();
|
||||
self.place_dragon_parts();
|
||||
}
|
||||
/// The dragon's parts as a server's client attacks them: the server's
|
||||
/// ids for `EnderDragonPart`s follow the dragon's.
|
||||
pub(super) fn remote_dragon_part_hit(
|
||||
&self,
|
||||
eye: [f64; 3],
|
||||
dir: [f64; 3],
|
||||
reach: f64,
|
||||
) -> Option<(i32, f64, [f64; 3])> {
|
||||
let d = self.dragon.as_ref().filter(|d| d.alive() && d.id != 0)?;
|
||||
(0..8)
|
||||
.filter_map(|i| {
|
||||
let b = d.part_box(i);
|
||||
let p = d.parts[i].0;
|
||||
super::ray_box(eye, dir, reach, b)
|
||||
.map(|t| ((d.id + 1 + i as u32) as i32, t, [p.x, p.y, p.z]))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -129,15 +129,32 @@ impl EndState {
|
||||
survive: false,
|
||||
});
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
|
||||
/// `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) {
|
||||
for e in &mut self.eyes {
|
||||
e.previous = e.position;
|
||||
e.life += 1;
|
||||
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
|
||||
e.position += e.velocity;
|
||||
}
|
||||
}
|
||||
/// A server's move: an eye has no interpolation handler, so
|
||||
/// `moveOrInterpolateTo` puts it there at once. False when `id` isn't one.
|
||||
pub(super) fn move_remote_eye(&mut self, id: u32, at: [f64; 3]) -> bool {
|
||||
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
|
||||
return false;
|
||||
};
|
||||
e.position = glam::DVec3::from_array(at);
|
||||
true
|
||||
}
|
||||
/// `handleSetEntityMotion` for an eye. False when `id` isn't one.
|
||||
pub(super) fn remote_eye_motion(&mut self, id: u32, velocity: [f64; 3]) -> bool {
|
||||
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
|
||||
return false;
|
||||
};
|
||||
e.velocity = glam::DVec3::from_array(velocity);
|
||||
true
|
||||
}
|
||||
pub(super) fn remove_remote_eye(&mut self, id: u32) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
}
|
||||
@@ -1099,6 +1116,7 @@ impl Game<'_> {
|
||||
/// EyeOfEnder.tick: fly, then drop (4 in 5) or shatter after 80 ticks.
|
||||
fn tick_eyes(&mut self) {
|
||||
let mut eyes = std::mem::take(&mut self.end.eyes);
|
||||
let mut shattered = Vec::new();
|
||||
eyes.retain_mut(|eye| {
|
||||
eye.previous = eye.position;
|
||||
let next = eye.position + eye.velocity;
|
||||
@@ -1122,10 +1140,44 @@ impl Game<'_> {
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stack, at, [vx, 0.2, vz], 0);
|
||||
} else {
|
||||
shattered.push(at.map(|v| v.floor() as i32));
|
||||
}
|
||||
false
|
||||
});
|
||||
self.end.eyes = eyes;
|
||||
for pos in shattered {
|
||||
self.level_event_at(2003, pos, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelEventHandler` 2003: a shattered eye's item crumbs and two rings
|
||||
/// of portal particles drawn in towards it.
|
||||
pub(super) fn eye_break_particles(&mut self, pos: [i32; 3]) {
|
||||
let (x, y, z) = (
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
);
|
||||
let crumbs = self.item_particle(&ItemStack::new("minecraft:ender_eye", 1));
|
||||
for _ in 0..8 {
|
||||
let r = self.particles.random();
|
||||
let v = [
|
||||
r.next_gaussian() * 0.15,
|
||||
r.next_double() * 0.2,
|
||||
r.next_gaussian() * 0.15,
|
||||
];
|
||||
self.particles.add(&crumbs, [x, y, z], v);
|
||||
}
|
||||
let portal = ParticleOptions::simple(ParticleType::Portal);
|
||||
let mut angle = 0.0f64;
|
||||
while angle < std::f64::consts::PI * 2.0 {
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
let at = [x + c * 5.0, y - 0.4, z + s * 5.0];
|
||||
self.particles.add(&portal, at, [c * -5.0, 0.0, s * -5.0]);
|
||||
self.particles.add(&portal, at, [c * -7.0, 0.0, s * -7.0]);
|
||||
angle += std::f64::consts::PI / 20.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `EyeOfEnder.tick` on a client: behind each eye (a quarter of its last
|
||||
|
||||
@@ -17,6 +17,8 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17;
|
||||
|
||||
pub(super) struct Rocket {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
pub id: u32,
|
||||
@@ -261,6 +263,14 @@ impl Game<'_> {
|
||||
/// `FireworkRocketEntity.explode` and `dealExplosionDamage`.
|
||||
fn explode_rocket(&mut self, r: &Rocket) {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
// FireworkRocketEntity.explode broadcasts before discard. Send now:
|
||||
// tick_lan removes missing tracked entities before flushing effects.
|
||||
if r.id != 0
|
||||
&& let Some(host) = &self.lan
|
||||
{
|
||||
host.hub
|
||||
.entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at);
|
||||
}
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
let from = r.position;
|
||||
@@ -322,6 +332,16 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
}
|
||||
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
|
||||
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
|
||||
let camera = self.eye_position();
|
||||
if let Some(r) = self.rockets.iter().find(|r| r.id == id as u32) {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at));
|
||||
}
|
||||
}
|
||||
/// `dealExplosionDamage` on a player that is not the rocket's
|
||||
/// `attachedToEntity`: within 5 blocks, when the rocket sees its feet or
|
||||
/// its middle (`ClipContext.Block.COLLIDER`, no fluids).
|
||||
|
||||
@@ -355,12 +355,23 @@ impl Game<'_> {
|
||||
* glam::DMat4::from_rotation_y(y_rot.to_radians());
|
||||
let light = self.world.light(f.pos[0], f.pos[1], f.pos[2]);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// ItemFrameRenderer.getBlockLightLevel: a glow frame's model is at least 5 bright.
|
||||
let frame_light = if f.glow {
|
||||
(light & 0xF0) | (light & 15).max(5)
|
||||
} else {
|
||||
light
|
||||
};
|
||||
let frame_packed =
|
||||
(u32::from(frame_light >> 4) * 16) | ((u32::from(frame_light & 15) * 16) << 8);
|
||||
let mut quad =
|
||||
|m: &glam::DMat4, corners: [[f64; 3]; 4], uv: [[f32; 2]; 4], light: u32| {
|
||||
for (c, uv) in corners.iter().zip(uv) {
|
||||
let p = m.transform_point3(glam::DVec3::from_array(*c));
|
||||
// The frame model is drawn with the entity shader, lit by its face normal.
|
||||
let at = corners.map(|c| m.transform_point3(glam::DVec3::from_array(c)));
|
||||
let n = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
|
||||
let normal = [0, 1, 2].map(|k| (n[k].clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
for (p, uv) in at.into_iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
normal: [normal[0], normal[1], normal[2], -127],
|
||||
pos: p.as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -393,7 +404,7 @@ impl Game<'_> {
|
||||
]
|
||||
}),
|
||||
map_uv(r, *uv),
|
||||
packed,
|
||||
frame_packed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ impl Game<'_> {
|
||||
before(self.painting_hit().map(|h| h.1))
|
||||
|| before(self.frame_hit().map(|h| h.1))
|
||||
|| before(self.armor_stand_hit().map(|h| h.1))
|
||||
|| before(self.cushion_hit().map(|h| h.1))
|
||||
|| before(self.boat_hit().map(|h| h.1))
|
||||
|| before(self.cart_hit().map(|h| h.1))
|
||||
|| before(self.dragon_hit().map(|h| h.1))
|
||||
|
||||
@@ -222,7 +222,6 @@ impl Game<'_> {
|
||||
options.report = None;
|
||||
options.server = None;
|
||||
options.world = None;
|
||||
options.dev_mods.clear();
|
||||
self.join_world_rest()?;
|
||||
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
|
||||
let flags = Arc::new(Flags::default());
|
||||
@@ -292,6 +291,9 @@ impl Game<'_> {
|
||||
launch.create.as_ref(),
|
||||
)?)?;
|
||||
let saved = Box::new(self.level.clone());
|
||||
// IntegratedServer runs the server mods; the window the client mods.
|
||||
self.mods.dev = self.options.dev_mods.clone();
|
||||
self.start_mods();
|
||||
// Math.max(2, options.renderDistance): past vanilla's 32 when the
|
||||
// window's render distance is (Horizon's options go to 64).
|
||||
let view_distance = self.settings.render_distance.max(2);
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, leash::Holder},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -71,6 +71,8 @@ pub(super) struct LanHost {
|
||||
/// Guests' commands waiting for the hub to be back in `self.lan`, where
|
||||
/// a command can reach every player (game/lan_actions.rs).
|
||||
pub commands: Vec<super::lan_actions::GuestCommand>,
|
||||
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
|
||||
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
|
||||
/// The entities the guests track, described again every tick
|
||||
/// (`lan_entities`) over last tick's states.
|
||||
states: lan::entity::States,
|
||||
@@ -145,6 +147,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
player_entity: Option<i32>,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
@@ -194,6 +197,25 @@ fn mob_meta(
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
let target = if e.flag {
|
||||
m.ai.target.and_then(|who| match who {
|
||||
Who::Player => player_entity,
|
||||
Who::Mob(id) => Some(id as i32),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(target) = target {
|
||||
meta.push((17, serializer::INT, Value::Int(target)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0, flying by default.
|
||||
"minecraft:bat" => {
|
||||
if e.flag {
|
||||
@@ -603,6 +625,7 @@ impl Game<'_> {
|
||||
waypoint_dead: Default::default(),
|
||||
border_sent: None,
|
||||
commands: Vec::new(),
|
||||
quiet: std::collections::VecDeque::new(),
|
||||
states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
@@ -828,12 +851,13 @@ impl Game<'_> {
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity);
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity),
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
});
|
||||
@@ -843,7 +867,7 @@ impl Game<'_> {
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
|
||||
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
@@ -968,6 +992,9 @@ impl Game<'_> {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::PlayerChat { sender, text } => {
|
||||
self.mod_player_chat(super::server_mods::Who::Uuid(sender), &text);
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
@@ -1037,6 +1064,14 @@ impl Game<'_> {
|
||||
}
|
||||
host.states = states;
|
||||
}
|
||||
if !host.quiet.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_quiet_commands();
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the quiet commands");
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
|
||||
@@ -77,6 +77,10 @@ pub(super) struct GuestPlay {
|
||||
last_inventory: u64,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
|
||||
/// its client's PERFORM_RESPAWN (`respawn_requested`).
|
||||
pub(super) won_game: bool,
|
||||
pub(super) respawn_requested: bool,
|
||||
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
|
||||
xp_delay: u32,
|
||||
/// What last killed the guest outright (`/kill`): the death message's key.
|
||||
@@ -94,11 +98,14 @@ pub(super) struct GuestPlay {
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
pub(super) struct GuestCommand {
|
||||
conn: u32,
|
||||
command: String,
|
||||
permission: u8,
|
||||
pub(super) conn: u32,
|
||||
pub(super) command: String,
|
||||
pub(super) permission: u8,
|
||||
/// An integrated server's: no level teleports (yet).
|
||||
integrated: bool,
|
||||
pub(super) integrated: bool,
|
||||
/// A mod's quiet command (game/lan_quiet.rs): its output suppressed, as
|
||||
/// `CommandSourceStack.withSuppressedOutput` does.
|
||||
pub(super) quiet: bool,
|
||||
}
|
||||
|
||||
/// The game's own state while a guest's is swapped in (`enter_guest`), and
|
||||
@@ -872,7 +879,11 @@ impl Game<'_> {
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| {
|
||||
g.menu.as_ref().is_some_and(|m| {
|
||||
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
|
||||
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|
||||
|| m.pos.is_some_and(|k| {
|
||||
k.1 == super::minecarts::CART_KEY_Y
|
||||
&& !self.chest_vehicle_still_valid(k.2, g)
|
||||
})
|
||||
})
|
||||
})
|
||||
.map(|g| g.conn)
|
||||
@@ -886,6 +897,35 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
|
||||
/// boat is still there and within the guest's entity interaction range
|
||||
/// plus 4 of its box.
|
||||
fn chest_vehicle_still_valid(&self, id: i32, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
let bounds = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id as i32 == id)
|
||||
.map(|c| c.bounds())
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id as i32 == id)
|
||||
.map(|b| b.bounds())
|
||||
});
|
||||
let Some(b) = bounds else {
|
||||
return false;
|
||||
};
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let d = [
|
||||
eye[0] - eye[0].clamp(lo.x, hi.x),
|
||||
eye[1] - eye[1].clamp(lo.y, hi.y),
|
||||
eye[2] - eye[2].clamp(lo.z, hi.z),
|
||||
];
|
||||
let range = if g.creative() { 5.0 } else { 3.0 } + 4.0;
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
|
||||
}
|
||||
|
||||
/// A packet from a guest that the hub does not handle itself.
|
||||
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -1210,6 +1250,9 @@ impl Game<'_> {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
|
||||
S::CustomPayload(p) if p.channel == horizon_protocol::quiet::CHANNEL => {
|
||||
self.guest_quiet_commands(host, conn, &p.data)
|
||||
}
|
||||
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
|
||||
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
|
||||
@@ -1308,6 +1351,13 @@ impl Game<'_> {
|
||||
self.guest_suggestions(host, conn, &payload);
|
||||
}
|
||||
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
|
||||
// handleClientCommand: PERFORM_RESPAWN after the poem leaves the End
|
||||
// (the next server tick sends the player on).
|
||||
S::ClientCommand(c)
|
||||
if c.action == 0 && host.play.get(&conn).is_some_and(|p| p.won_game) =>
|
||||
{
|
||||
host.play.entry(conn).or_default().respawn_requested = true;
|
||||
}
|
||||
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
|
||||
// ServerStatsCounter.sendStats.
|
||||
S::ClientCommand(c) if c.action == 1 => {
|
||||
@@ -1533,6 +1583,10 @@ impl Game<'_> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A server mod's command, for every player whatever their level.
|
||||
if self.guest_server_mod_command(host, conn, command.trim_start_matches('/')) {
|
||||
return;
|
||||
}
|
||||
// An integrated server runs every player's commands at their own
|
||||
// permission level (`CommandSourceStack.permissions`); a LAN world
|
||||
// follows its Allow Commands, and a dedicated server leaves it to
|
||||
@@ -1559,6 +1613,7 @@ impl Game<'_> {
|
||||
command: command.trim_start_matches('/').to_owned(),
|
||||
permission,
|
||||
integrated,
|
||||
quiet: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1573,41 +1628,54 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let c = host.commands.remove(0);
|
||||
let mut host = self.lan.take().expect("checked above");
|
||||
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
|
||||
self.lan = Some(host);
|
||||
continue;
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
|
||||
self.run_command(&c.command);
|
||||
self.ui.registries.permission = level;
|
||||
let lines = self.chat_capture.take().unwrap_or_default();
|
||||
let mut host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
if !c.integrated {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
self.run_guest_command(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// One guest's command as that guest, its feedback sent to them; false
|
||||
/// when it failed (or the guest is gone).
|
||||
pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
|
||||
self.lan = Some(host);
|
||||
return false;
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
|
||||
let ok = if c.quiet {
|
||||
self.run_command_quietly(&c.command)
|
||||
} else {
|
||||
self.run_command(&c.command)
|
||||
};
|
||||
self.ui.registries.permission = level;
|
||||
let lines = self.chat_capture.take().unwrap_or_default();
|
||||
let mut host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
if !c.integrated {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
ok
|
||||
}
|
||||
|
||||
/// The `broadcastToAdmins` lines a command queued (`source`: the guest
|
||||
/// whose it was; none for the host's or the console's): every other
|
||||
/// operator gets them, and the console logs them as the server's own
|
||||
|
||||
@@ -28,7 +28,11 @@ impl Game<'_> {
|
||||
|
||||
/// SignableCommand.of: the command's message arguments, by name, with
|
||||
/// their text, not past a redirect to the root (`execute ... run`).
|
||||
fn signable_arguments(&mut self, permission: u8, command: &str) -> Vec<(String, String)> {
|
||||
pub(super) fn signable_arguments(
|
||||
&mut self,
|
||||
permission: u8,
|
||||
command: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let parse = self.parse_as(permission, command);
|
||||
let nodes = &vanilla::game_dispatcher().nodes;
|
||||
let root = parse.context.root;
|
||||
|
||||
@@ -245,6 +245,16 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
for c in &self.cushions {
|
||||
let p = c.position;
|
||||
let mut e = state(c.id, "minecraft:cushion", [p.x, p.y, p.z], c.yaw, false);
|
||||
// Cushion.DATA_COLOR, set unless it is the default white.
|
||||
if c.color != 0 {
|
||||
e.meta
|
||||
.push((8, serializer::DYE_COLOR, Value::Id(i32::from(c.color))));
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
self.lan_projectile_states(host_entity, hub, out);
|
||||
for o in &self.others {
|
||||
let p = o.position;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Quiet commands (`horizon_protocol::quiet`): a Horizon client's mod sends a
|
||||
//! batch of command lines in one payload, and the server runs each exactly as
|
||||
//! that player typing it would (`guest_command`'s checks, the player's own
|
||||
//! permission level, at the time it runs) with its output suppressed, as
|
||||
//! vanilla's functions run (`CommandSourceStack.withSuppressedOutput`): no
|
||||
//! feedback and no failure in anyone's chat. A batch waits in a queue and runs
|
||||
//! [`PER_TICK`] lines a player a tick, so a mod can't flood a server; each
|
||||
//! finished batch is one line in the server's log.
|
||||
use super::{Game, lan::LanHost, lan_actions::GuestCommand};
|
||||
use horizon_protocol::quiet;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
/// Lines a player may have waiting at most; a batch past it loses the rest.
|
||||
pub(super) const PENDING: usize = 4096;
|
||||
/// Lines run a player a tick at most.
|
||||
pub(super) const PER_TICK: usize = 64;
|
||||
|
||||
/// A batch waiting to run.
|
||||
pub(super) struct QuietBatch {
|
||||
conn: u32,
|
||||
mod_id: String,
|
||||
lines: VecDeque<String>,
|
||||
ran: usize,
|
||||
failed: usize,
|
||||
/// Lines past [`PENDING`], never run.
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A `horizon:quiet_commands` payload from `conn`: queued (or logged as
|
||||
/// refused when it breaks the payload's caps).
|
||||
pub(super) fn guest_quiet_commands(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
|
||||
let Some(name) = host.hub.guest(conn).map(|g| g.name.clone()) else {
|
||||
return;
|
||||
};
|
||||
let batch = match quiet::decode(data) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
super::dedicated::log(
|
||||
"WARN",
|
||||
&format!("{name} sent quiet commands that were refused: {e}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let waiting: usize = host
|
||||
.quiet
|
||||
.iter()
|
||||
.filter(|b| b.conn == conn)
|
||||
.map(|b| b.lines.len())
|
||||
.sum();
|
||||
let room = PENDING.saturating_sub(waiting);
|
||||
let dropped = batch.lines.len().saturating_sub(room);
|
||||
host.quiet.push_back(QuietBatch {
|
||||
conn,
|
||||
mod_id: batch.mod_id,
|
||||
lines: batch.lines.into_iter().take(room).collect(),
|
||||
ran: 0,
|
||||
failed: 0,
|
||||
dropped,
|
||||
});
|
||||
}
|
||||
|
||||
/// The waiting batches, [`PER_TICK`] lines a player this tick, in order.
|
||||
pub(super) fn run_quiet_commands(&mut self) {
|
||||
let mut left: HashMap<u32, usize> = HashMap::new();
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(batch) = host.quiet.get_mut(i) else {
|
||||
return;
|
||||
};
|
||||
let conn = batch.conn;
|
||||
let budget = left.entry(conn).or_insert(PER_TICK);
|
||||
let gone = host.hub.guest(conn).is_none();
|
||||
if gone || batch.lines.is_empty() {
|
||||
let batch = host.quiet.remove(i).expect("got it above");
|
||||
if !gone {
|
||||
let name = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map_or(String::new(), |g| g.name.clone());
|
||||
super::dedicated::log("INFO", &summary(&name, &batch));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *budget == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
*budget -= 1;
|
||||
let line = batch.lines.pop_front().expect("not empty");
|
||||
let ok = self.run_quiet_line(conn, &line);
|
||||
if let Some(batch) = self.lan.as_mut().and_then(|h| h.quiet.get_mut(i)) {
|
||||
batch.ran += 1;
|
||||
batch.failed += usize::from(!ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line as `guest_command` would run it for `conn`, quietly. False
|
||||
/// when it is refused or fails. A command that carries a chat message
|
||||
/// (`say`, `msg`...) is refused: it would need the player's signature.
|
||||
fn run_quiet_line(&mut self, conn: u32, line: &str) -> bool {
|
||||
let allowed = self.options.dedicated.is_some() || self.allow_commands();
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let integrated = host.hub.config.integrated;
|
||||
if !integrated && (!host.settings.guest_commands || !allowed) {
|
||||
return false;
|
||||
}
|
||||
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
return false;
|
||||
};
|
||||
let command = line.trim_start_matches('/');
|
||||
if !self.signable_arguments(permission, command).is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.run_guest_command(GuestCommand {
|
||||
conn,
|
||||
command: command.to_owned(),
|
||||
permission,
|
||||
integrated,
|
||||
quiet: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The batch's line in the server's log.
|
||||
fn summary(name: &str, b: &QuietBatch) -> String {
|
||||
let mut line = format!(
|
||||
"{name} ran {} quiet command(s) from mod {}: {} failed",
|
||||
b.ran, b.mod_id, b.failed
|
||||
);
|
||||
if b.dropped > 0 {
|
||||
line += &format!(", {} dropped (more than {PENDING} waiting)", b.dropped);
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_log_line_counts_the_batch() {
|
||||
let b = QuietBatch {
|
||||
conn: 1,
|
||||
mod_id: "orbital".into(),
|
||||
lines: VecDeque::new(),
|
||||
ran: 12,
|
||||
failed: 3,
|
||||
dropped: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
summary("Alex", &b),
|
||||
"Alex ran 12 quiet command(s) from mod orbital: 3 failed"
|
||||
);
|
||||
let b = QuietBatch { dropped: 5, ..b };
|
||||
assert!(summary("Alex", &b).ends_with(", 5 dropped (more than 4096 waiting)"));
|
||||
}
|
||||
}
|
||||
@@ -144,15 +144,16 @@ impl Game<'_> {
|
||||
.offers
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let cost =
|
||||
|st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| play::NetCost {
|
||||
item: hub.item_id(&st.item),
|
||||
count: st.count as i32,
|
||||
};
|
||||
let cost = |st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| {
|
||||
hub.stack(&Some(st.clone())).map(play::NetCost::from)
|
||||
};
|
||||
Some(play::NetOffer {
|
||||
cost_a: cost(&o.base_cost_a.stack, &mut host.hub),
|
||||
cost_a: cost(&o.base_cost_a.stack, &mut host.hub)?,
|
||||
result: host.hub.stack(&Some(o.result.clone()))?,
|
||||
cost_b: o.cost_b.as_ref().map(|b| cost(&b.stack, &mut host.hub)),
|
||||
cost_b: match &o.cost_b {
|
||||
Some(b) => Some(cost(&b.stack, &mut host.hub)?),
|
||||
None => None,
|
||||
},
|
||||
out_of_stock: o.out_of_stock(),
|
||||
uses: o.uses,
|
||||
max_uses: o.max_uses,
|
||||
|
||||
@@ -56,6 +56,13 @@ impl Game<'_> {
|
||||
let (lo, hi) = corners(a.bounds());
|
||||
return Some((lo, hi, at(a.position)));
|
||||
}
|
||||
if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
|
||||
let (lo, hi) = corners(Aabb::new(
|
||||
Vec3::new(c.position.x - 0.5, c.position.y, c.position.z - 0.5),
|
||||
Vec3::new(c.position.x + 0.5, c.position.y + 0.25, c.position.z + 0.5),
|
||||
));
|
||||
return Some((lo, hi, at(c.position)));
|
||||
}
|
||||
if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
|
||||
let (lo, hi) = f.bounds();
|
||||
return Some((lo, hi, centre(f.pos)));
|
||||
@@ -98,9 +105,10 @@ impl Game<'_> {
|
||||
let boat = self.boats.iter().position(|b| b.id == vehicle);
|
||||
let cart = self.carts.iter().any(|c| c.id == vehicle);
|
||||
let mount = self.mobs.iter().any(|m| m.id == vehicle);
|
||||
let cushion = self.cushions.iter().any(|c| c.id == vehicle);
|
||||
// The vehicle is gone (broken, in another level) or the rider died:
|
||||
// its client drops it with the entity or the death screen.
|
||||
if dead || (boat.is_none() && !cart && !mount) {
|
||||
if dead || (boat.is_none() && !cart && !mount && !cushion) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.vehicle, g.vehicle_good) = (None, None);
|
||||
return;
|
||||
@@ -115,6 +123,17 @@ impl Game<'_> {
|
||||
// Getting out goes through leave_guest, which tells the client and
|
||||
// teleports it (ServerPlayer.dismountTo).
|
||||
let seat = self.as_guest(host, conn, |game| {
|
||||
// A cushion does not move: the rider sits on it until it sneaks
|
||||
// (`Cushion.removePassenger` plays the get-up sound).
|
||||
if game.cushions.iter().any(|c| Some(c.id) == game.riding) {
|
||||
if keys.shift {
|
||||
game.tick_cushion_riding(keys);
|
||||
return None;
|
||||
}
|
||||
let c = game.cushions.iter().find(|c| Some(c.id) == game.riding)?;
|
||||
let p = c.seat();
|
||||
return Some([p.x, p.y, p.z]);
|
||||
}
|
||||
if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) {
|
||||
if keys.shift {
|
||||
game.dismount(i);
|
||||
@@ -168,6 +187,12 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.cushions
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| (c.position, c.seat()))
|
||||
});
|
||||
if let Some((p, seat)) = at {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
|
||||
@@ -50,6 +50,7 @@ pub(super) struct Parked {
|
||||
paintings: Vec<horizon_client::entities::Painting>,
|
||||
item_frames: Vec<horizon_client::entities::ItemFrame>,
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
others: Vec<commands::OtherEntity>,
|
||||
boats: Vec<boats::Boat>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
@@ -136,6 +137,7 @@ impl Game<'_> {
|
||||
paintings: Vec::new(),
|
||||
item_frames: Vec::new(),
|
||||
armor_stands: Vec::new(),
|
||||
cushions: Vec::new(),
|
||||
others: Vec::new(),
|
||||
boats: Vec::new(),
|
||||
carts: Vec::new(),
|
||||
@@ -187,6 +189,7 @@ impl Game<'_> {
|
||||
swap(&mut self.paintings, &mut p.paintings);
|
||||
swap(&mut self.item_frames, &mut p.item_frames);
|
||||
swap(&mut self.armor_stands, &mut p.armor_stands);
|
||||
swap(&mut self.cushions, &mut p.cushions);
|
||||
swap(&mut self.others, &mut p.others);
|
||||
swap(&mut self.boats, &mut p.boats);
|
||||
swap(&mut self.carts, &mut p.carts);
|
||||
|
||||
@@ -88,8 +88,8 @@ pub(super) struct Minecart {
|
||||
/// Hopper minecarts: an unpowered activator rail enables them.
|
||||
pub enabled: bool,
|
||||
}
|
||||
const WIDTH: f64 = 0.98;
|
||||
const HEIGHT: f64 = 0.7;
|
||||
pub(super) const WIDTH: f64 = 0.98;
|
||||
pub(super) const HEIGHT: f64 = 0.7;
|
||||
/// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block.
|
||||
fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
@@ -97,7 +97,7 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
pub(super) const CART_KEY_Y: i32 = -100_000;
|
||||
pub(super) const CART_KEY_Y: i32 = horizon_client::lan::menu::VEHICLE_KEY_Y;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum Shape {
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::{path::PathBuf, time::Duration};
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
super::Screen,
|
||||
super::{execution_role::ExecutionRole, lan::LanHost, server_mods::ModEvent},
|
||||
horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud,
|
||||
@@ -28,7 +29,8 @@ use {
|
||||
ui,
|
||||
},
|
||||
horizon_mods::{
|
||||
Clock, ModKey, Mods, Output, Player, Runs, Snapshot, Status, UiScreen, UiWidget,
|
||||
Clock, ModKey, Mods, Output, Player, Runs, ServerPlayer, Snapshot, Status, UiScreen,
|
||||
UiWidget,
|
||||
},
|
||||
std::time::Instant,
|
||||
winit::{event::MouseButton, keyboard::KeyCode},
|
||||
@@ -52,6 +54,15 @@ pub(super) struct ModsState {
|
||||
/// The chat's command tree while a running mod owns commands.
|
||||
#[cfg(feature = "mods")]
|
||||
tree: Option<Dispatcher>,
|
||||
/// A server mod runs here: what players do is queued for it.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) server: bool,
|
||||
/// The players server mods saw last tick, for joins and leaves.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) players: Vec<ServerPlayer>,
|
||||
/// What players did this tick, for server mods at its end.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) events: Vec<ModEvent>,
|
||||
/// `command.run` lines for this window's own world, run on the next tick:
|
||||
/// run at once, their chat lines would reach the mods while their output
|
||||
/// is still being shown, and a mod answering its own lines would recurse.
|
||||
@@ -272,7 +283,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let enabled = &self.settings.mods.enabled;
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let mods = Mods::load_for(Runs::Client, &paths, on, vanilla_command);
|
||||
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
@@ -282,8 +293,18 @@ impl Game<'_> {
|
||||
self.sync_mod_commands();
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Which mods this game runs: a server the server mods, the window
|
||||
/// client mods, and both when it simulates the world itself (in-frame).
|
||||
fn mod_runs(&self) -> Runs {
|
||||
match self.options.execution_role {
|
||||
ExecutionRole::IntegratedServer | ExecutionRole::DedicatedServer => Runs::Server,
|
||||
ExecutionRole::Client if self.options.server_thread => Runs::Client,
|
||||
ExecutionRole::Client => Runs::Both,
|
||||
}
|
||||
}
|
||||
/// The Mods page's rows, from what loaded.
|
||||
fn list_mods(&mut self) {
|
||||
self.mods.server = self.mods.mods.runs_server();
|
||||
let dev = &self.mods.dev;
|
||||
self.settings.mods.installed = self
|
||||
.mods
|
||||
@@ -369,8 +390,18 @@ impl Game<'_> {
|
||||
self.start_mods();
|
||||
}
|
||||
}
|
||||
/// What mods read this tick (or frame, for mods with a `frame` hook).
|
||||
/// What mods read this tick (or frame, for mods with a `frame` hook): the
|
||||
/// server's players as they were at the last tick's end.
|
||||
fn mod_snapshot(&mut self) -> Snapshot {
|
||||
let players = if self.mods.server {
|
||||
self.mods.players.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.mod_snapshot_with(players)
|
||||
}
|
||||
/// What mods read, with these players on the server.
|
||||
pub(super) fn mod_snapshot_with(&mut self, players: Vec<ServerPlayer>) -> Snapshot {
|
||||
let player = self.player.as_ref().map(|p| Player {
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
@@ -414,7 +445,7 @@ impl Game<'_> {
|
||||
mappings,
|
||||
cps,
|
||||
font_widths: self.mods.font_widths.clone(),
|
||||
players: Vec::new(),
|
||||
players,
|
||||
view_hit: self.mod_view_hit(),
|
||||
}
|
||||
}
|
||||
@@ -490,23 +521,32 @@ impl Game<'_> {
|
||||
self.mods.mods.tick(self.ticks);
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Once per frame: the frame hook, and dev mods' rebuilds.
|
||||
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
||||
if !self.mods.dev.is_empty()
|
||||
&& self
|
||||
/// Restarts the dev mods rebuilt since the last check, once a second.
|
||||
pub(super) fn reload_dev_mods(&mut self) {
|
||||
if self.mods.dev.is_empty()
|
||||
|| self
|
||||
.mods
|
||||
.checked
|
||||
.is_none_or(|t| t.elapsed() >= RELOAD_CHECK)
|
||||
.is_some_and(|t| t.elapsed() < RELOAD_CHECK)
|
||||
{
|
||||
self.mods.checked = Some(Instant::now());
|
||||
if self.mods.mods.reload_changed(vanilla_command) {
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
return;
|
||||
}
|
||||
self.mods.checked = Some(Instant::now());
|
||||
if self.mods.mods.reload_changed(vanilla_command) {
|
||||
let chosen = &self.settings.mods;
|
||||
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
if self.options.execution_role.is_client() {
|
||||
self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY));
|
||||
} else {
|
||||
log::info!("Horizon: mods reloaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Once per frame: the frame hook, and dev mods' rebuilds.
|
||||
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
|
||||
self.reload_dev_mods();
|
||||
if !self.mods.mods.is_running() {
|
||||
return;
|
||||
}
|
||||
@@ -537,8 +577,13 @@ impl Game<'_> {
|
||||
self.mods.mods.chat(line);
|
||||
self.show_mod_output();
|
||||
}
|
||||
/// Chat lines, command feedback and stopped mods, into the chat.
|
||||
fn show_mod_output(&mut self) {
|
||||
/// What mods showed: client mods' lines into the chat, server mods'
|
||||
/// to their players.
|
||||
pub(super) fn show_mod_output(&mut self) {
|
||||
self.route_mod_output(None);
|
||||
}
|
||||
/// [`Game::show_mod_output`], with the hub when it is out of `self.lan`.
|
||||
pub(super) fn route_mod_output(&mut self, mut host: Option<&mut LanHost>) {
|
||||
for output in self.mods.mods.drain() {
|
||||
let line = match output {
|
||||
Output::Chat { name, text } => {
|
||||
@@ -566,7 +611,16 @@ impl Game<'_> {
|
||||
// `mod_ui_view` finds no widgets and the screen closes.
|
||||
self.list_mods();
|
||||
self.sync_mod_commands();
|
||||
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
|
||||
let line = format!("Mod {name} stopped: {reason}");
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log("WARN", &line);
|
||||
}
|
||||
Text::literal(line).with_color(text::RED)
|
||||
}
|
||||
server
|
||||
@ (Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. }) => {
|
||||
self.route_server_output(host.as_deref_mut(), server);
|
||||
continue;
|
||||
}
|
||||
Output::OpenScreen { owner } => {
|
||||
self.open_mod_screen(owner, false);
|
||||
@@ -582,20 +636,8 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Server mods' lines: this game runs no server mod yet.
|
||||
Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. } => continue,
|
||||
};
|
||||
// Not through system_message: a mod must not hear its own lines.
|
||||
// Written by this client, so the Chat option doesn't hide it.
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
self.add_mod_chat_line(line);
|
||||
}
|
||||
// A mod may set or clear its settings page in any call, not only
|
||||
// while loading: its row's Settings button follows.
|
||||
@@ -604,6 +646,20 @@ impl Game<'_> {
|
||||
row.has_settings = e.has_ui_settings();
|
||||
}
|
||||
}
|
||||
/// A mod's line in the window's chat; not through system_message: a mod
|
||||
/// must not hear its own lines.
|
||||
/// Written by this client, so the Chat option doesn't hide it.
|
||||
pub(super) fn add_mod_chat_line(&mut self, line: Text) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
}
|
||||
/// The mods' HUD panels, stacked with the extras in their corners.
|
||||
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
for panel in self.mods.mods.panels() {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! window, waits until it stands in the world with the chunks around it,
|
||||
//! sends a chat message and `/list`, collects the chat it is shown, leaves
|
||||
//! and writes a JSON report (position, chunks, chat lines, whether the
|
||||
//! server kept it connected throughout).
|
||||
//! server kept it connected throughout). With `HORIZON_NET_QUIET` (lines
|
||||
//! split by `;`) it then sends them as one batch of a mod's quiet commands
|
||||
//! (`horizon_protocol::quiet`, `tools/quiet_commands_e2e.py`) and waits two
|
||||
//! seconds for any chat they bring.
|
||||
use super::Game;
|
||||
use horizon_client::Result;
|
||||
use serde_json::json;
|
||||
@@ -23,6 +26,9 @@ impl Game<'_> {
|
||||
let mut step_at = Instant::now();
|
||||
let mut chat_at = 0;
|
||||
let mut position = None;
|
||||
let quiet: Vec<String> = std::env::var("HORIZON_NET_QUIET")
|
||||
.map(|v| v.split(';').map(str::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
let mut chunks = 0;
|
||||
let mut error = None;
|
||||
while Instant::now() < deadline {
|
||||
@@ -71,6 +77,22 @@ impl Game<'_> {
|
||||
step_at = now;
|
||||
chat_at = shown;
|
||||
}
|
||||
2 if answered && !quiet.is_empty() => {
|
||||
for data in horizon_protocol::quiet::encode("net_harness", &quiet) {
|
||||
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
|
||||
horizon_protocol::packets::configuration::CustomPayload {
|
||||
channel: horizon_protocol::quiet::CHANNEL.into(),
|
||||
data,
|
||||
},
|
||||
));
|
||||
}
|
||||
step = 4;
|
||||
step_at = now;
|
||||
}
|
||||
4 if now - step_at > Duration::from_secs(2) => {
|
||||
step = 3;
|
||||
break;
|
||||
}
|
||||
2 if answered => {
|
||||
step = 3;
|
||||
break;
|
||||
|
||||
@@ -454,11 +454,24 @@ impl Game<'_> {
|
||||
for m in &self.mobs {
|
||||
self.portals.before.insert(m.id, m.position);
|
||||
}
|
||||
for b in &self.boats {
|
||||
self.portals.before.insert(b.id, b.position);
|
||||
}
|
||||
for c in &self.carts {
|
||||
self.portals.before.insert(c.id, c.position);
|
||||
}
|
||||
let ids: Vec<(u32, bool)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.map(|b| b.id)
|
||||
.chain(self.carts.iter().map(|c| c.id))
|
||||
.map(|id| (id, !self.vehicle_occupied(id))),
|
||||
)
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
@@ -501,8 +514,15 @@ impl Game<'_> {
|
||||
&& self.riding != Some(m.id)
|
||||
&& m.species() != Species::Wither
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` for the items and mobs that moved this
|
||||
/// tick: the portal blocks their box swept through.
|
||||
/// Whether the player, a server player or a mob rides vehicle `id`.
|
||||
fn vehicle_occupied(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
|
||||
/// and minecarts that moved this tick: the portal blocks their box swept
|
||||
/// through.
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -519,8 +539,23 @@ impl Game<'_> {
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let vehicles: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT))
|
||||
.chain(self.carts.iter().map(|c| {
|
||||
(
|
||||
c.id,
|
||||
c.position,
|
||||
super::minecarts::WIDTH,
|
||||
super::minecarts::HEIGHT,
|
||||
)
|
||||
}))
|
||||
.filter(|(id, ..)| !self.vehicle_occupied(*id))
|
||||
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
|
||||
.collect();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
@@ -678,6 +713,44 @@ impl Game<'_> {
|
||||
}
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = &self.boats[i];
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
let plan = self.portal_plan(entry, b.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = &self.boats[i];
|
||||
let tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.boats.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let c = &self.carts[i];
|
||||
let (width, height) = (
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
let plan = self.portal_plan(entry, c.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
|
||||
self.carts.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
|
||||
@@ -268,6 +268,11 @@ impl Game<'_> {
|
||||
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_hdr(
|
||||
s.horizon.hdr,
|
||||
s.horizon.hdr_paper_white as f32,
|
||||
s.horizon.hdr_peak as f32,
|
||||
);
|
||||
}
|
||||
// Window.updateFullscreenIfChanged: only a changed option asks the
|
||||
// window to change, so applying settings never undoes a fullscreen
|
||||
|
||||
@@ -314,6 +314,13 @@ impl Game<'_> {
|
||||
self.item_frames.push(f);
|
||||
}
|
||||
}
|
||||
"minecraft:cushion" => {
|
||||
self.next_entity += 1;
|
||||
if let Some(c) = entity_nbt::cushion_from_nbt(&tag, self.next_entity) {
|
||||
self.remember(c.id, uuid, tag);
|
||||
self.cushions.push(c);
|
||||
}
|
||||
}
|
||||
"minecraft:painting" => {
|
||||
if let Some((pos, facing, variant)) = entity_nbt::painting_from_nbt(&tag)
|
||||
&& let Some(v) = self.painting_variants.get(&variant).cloned()
|
||||
@@ -776,6 +783,18 @@ impl Game<'_> {
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for c in &self.cushions {
|
||||
if keys.contains(&chunk_of(c.position)) {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&c.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::cushion_to_nbt(c, uuid, self.persist.raw.get(&c.id));
|
||||
out.entry(chunk_of(c.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for p in &self.paintings {
|
||||
let at = painting_center(p);
|
||||
if keys.contains(&chunk_of(at)) {
|
||||
@@ -868,6 +887,13 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
self.cushions.retain(|c| {
|
||||
let keep = !gone(c.position);
|
||||
if !keep {
|
||||
removed.push(c.id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Riders leave with their root vehicle, a boat's with the boat.
|
||||
let leaving: HashSet<u32> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
@@ -934,6 +960,7 @@ impl Game<'_> {
|
||||
.chain(self.boats.iter().map(|e| e.id))
|
||||
.chain(self.carts.iter().map(|e| e.id))
|
||||
.chain(self.armor_stands.iter().map(|e| e.id))
|
||||
.chain(self.cushions.iter().map(|e| e.id))
|
||||
.chain(self.item_frames.iter().map(|e| e.id))
|
||||
.chain(self.paintings.iter().map(|e| e.id))
|
||||
.chain(self.others.iter().map(|e| e.id))
|
||||
|
||||
@@ -141,14 +141,22 @@ impl PotionState {
|
||||
t.stack = stack;
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
for t in &mut self.thrown {
|
||||
t.previous = t.position;
|
||||
t.age += 1;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
/// A server's move: thrown potions have no interpolation handler, so
|
||||
/// `moveOrInterpolateTo` puts them there at once. False when `id` isn't one.
|
||||
pub(super) fn move_remote_thrown(&mut self, id: u32, at: Vec3) -> bool {
|
||||
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
|
||||
return false;
|
||||
};
|
||||
t.position = at;
|
||||
true
|
||||
}
|
||||
/// `handleSetEntityMotion` for a thrown potion. False when `id` isn't one.
|
||||
pub(super) fn remote_thrown_motion(&mut self, id: u32, velocity: Vec3) -> bool {
|
||||
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
|
||||
return false;
|
||||
};
|
||||
t.velocity = velocity;
|
||||
true
|
||||
}
|
||||
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
|
||||
self.thrown.retain(|t| t.id != id);
|
||||
@@ -484,6 +492,7 @@ impl Game<'_> {
|
||||
thrown.retain_mut(|t| {
|
||||
t.age += 1;
|
||||
t.previous = t.position;
|
||||
self.thrown_potion_motion(t);
|
||||
let next = Vec3::new(
|
||||
t.position.x + t.velocity.x,
|
||||
t.position.y + t.velocity.y,
|
||||
@@ -520,18 +529,6 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
// ThrowableItemProjectile: drag 0.99 then gravity 0.05.
|
||||
// ThrownExperienceBottle.getDefaultGravity is 0.07.
|
||||
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
|
||||
0.07
|
||||
} else {
|
||||
0.05
|
||||
};
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x * 0.99,
|
||||
t.velocity.y * 0.99 - gravity,
|
||||
t.velocity.z * 0.99,
|
||||
);
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
@@ -539,6 +536,60 @@ impl Game<'_> {
|
||||
self.splash(&stack, at);
|
||||
}
|
||||
}
|
||||
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
|
||||
/// come before the move: gravity 0.05 (0.07 for an experience bottle),
|
||||
/// then drag 0.99 (0.8 in water).
|
||||
fn thrown_potion_motion(&self, t: &mut ThrownPotion) {
|
||||
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
|
||||
0.07
|
||||
} else {
|
||||
0.05
|
||||
};
|
||||
let at = t.position;
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
let inertia = if water { 0.8 } else { 0.99 };
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x * inertia,
|
||||
(t.velocity.y - gravity) * inertia,
|
||||
t.velocity.z * inertia,
|
||||
);
|
||||
}
|
||||
/// `ThrowableProjectile.tick` on a server's client: it falls and moves
|
||||
/// by itself between the server's updates and stops at a block; only
|
||||
/// the server breaks it.
|
||||
pub(super) fn step_remote_thrown(&mut self) {
|
||||
let mut thrown = std::mem::take(&mut self.potions.thrown);
|
||||
for t in &mut thrown {
|
||||
t.previous = t.position;
|
||||
t.age += 1;
|
||||
self.thrown_potion_motion(t);
|
||||
let next = Vec3::new(
|
||||
t.position.x + t.velocity.x,
|
||||
t.position.y + t.velocity.y,
|
||||
t.position.z + t.velocity.z,
|
||||
);
|
||||
let state = self.world.block(
|
||||
next.x.floor() as i32,
|
||||
next.y.floor() as i32,
|
||||
next.z.floor() as i32,
|
||||
);
|
||||
if self.res.facts().get(state).full_collision {
|
||||
t.velocity = Vec3::ZERO;
|
||||
} else {
|
||||
t.position = next;
|
||||
}
|
||||
}
|
||||
self.potions.thrown = thrown;
|
||||
}
|
||||
/// `ThrownItemRenderer` for thrown splash and lingering potions and
|
||||
/// experience bottles: the item (GROUND) facing the camera at scale 1,
|
||||
/// lit at the light probe (the eye, 0.85 of the 0.25 height).
|
||||
|
||||
@@ -839,7 +839,10 @@ impl Game<'_> {
|
||||
remote.brand = brand;
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => self.set_registries(registries),
|
||||
Event::Reconfigured(registries) => {
|
||||
self.boss_overlay.reset();
|
||||
self.set_registries(registries);
|
||||
}
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
// singleplayer" (no ServerData), so the world just closes.
|
||||
Event::Transfer { host, port } => {
|
||||
@@ -1258,6 +1261,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.res.mob_types().get(kind).cloned()
|
||||
};
|
||||
if kind == "minecraft:ender_dragon" {
|
||||
self.spawn_remote_dragon(id, position, yaw);
|
||||
}
|
||||
if kind == "minecraft:experience_orb" {
|
||||
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
@@ -1364,13 +1370,17 @@ impl Game<'_> {
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.creatures.projectiles.iter_mut().find(|p| {
|
||||
p.id == id as u32
|
||||
&& matches!(
|
||||
p.kind,
|
||||
horizon_client::projectiles::ProjectileKind::LargeFireball { .. }
|
||||
)
|
||||
}) {
|
||||
// Projectiles keep Entity's NO_OP InterpolationHandler too: the
|
||||
// server's position replaces theirs at once and their own tick
|
||||
// flies them on (`tick_remote_projectiles`). Eased towards it
|
||||
// instead, they only moved when an update came, every 10 to 20
|
||||
// ticks.
|
||||
if let Some(p) = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter_mut()
|
||||
.find(|p| p.id == id as u32)
|
||||
{
|
||||
if path.is_some() {
|
||||
p.position = Vec3::new(position[0], position[1], position[2]);
|
||||
}
|
||||
@@ -1380,6 +1390,14 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if path.is_some()
|
||||
&& (self.potions.move_remote_thrown(
|
||||
id as u32,
|
||||
Vec3::new(position[0], position[1], position[2]),
|
||||
) || self.end.move_remote_eye(id as u32, position))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
p.0 = position;
|
||||
}
|
||||
@@ -1426,6 +1444,8 @@ impl Game<'_> {
|
||||
self.mobs.retain(|m| m.id != id as u32);
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.dropped.retain(|d| d.id != id as u32);
|
||||
// Before the crystals go: the dragon's beam index follows them.
|
||||
self.remove_remote_dragon(id);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_projectile(id as u32);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -1958,6 +1978,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.remote_object_data(p.id, &p.values);
|
||||
self.remote_projectile_data(p.id, &p.values);
|
||||
self.remote_dragon_data(p.id, &p.values);
|
||||
}
|
||||
// ClientPacketListener.handleSetEquipment: what a mob or another
|
||||
// player holds and wears (EquipmentSlot ordinals).
|
||||
@@ -2039,6 +2060,8 @@ impl Game<'_> {
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
} else {
|
||||
self.remote_dragon_hurt(p.id);
|
||||
}
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
@@ -2048,9 +2071,14 @@ impl Game<'_> {
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
} else {
|
||||
self.remote_dragon_hurt(p.id);
|
||||
}
|
||||
}
|
||||
C::EntityEvent(p) => {
|
||||
if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT {
|
||||
self.remote_firework_explosion(p.id);
|
||||
}
|
||||
// LocalPlayer.setPermissions also refreshes an open World Options screen.
|
||||
if (24..=28).contains(&p.event)
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
@@ -2936,6 +2964,7 @@ impl Game<'_> {
|
||||
/// entities whose own tick moves them on a client: dropped items, falling
|
||||
/// blocks and primed TNT.
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
self.remote_dragon_motion(id, velocity);
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.velocity = v;
|
||||
@@ -2956,6 +2985,8 @@ impl Game<'_> {
|
||||
.and_then(|r| r.pending_items.get_mut(&id))
|
||||
{
|
||||
p.1 = v;
|
||||
} else if !self.potions.remote_thrown_motion(id as u32, v) {
|
||||
self.end.remote_eye_motion(id as u32, velocity);
|
||||
}
|
||||
}
|
||||
fn apply_remote_health(&mut self) {
|
||||
@@ -3062,6 +3093,12 @@ impl Game<'_> {
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
|
||||
// for a server's client (multiplayer and the default single player),
|
||||
// so the entries advance here or the slot stays shaded until the
|
||||
// server says the entry ended. `onCooldownEnded` is the plain
|
||||
// ItemCooldowns' no-op on a client.
|
||||
self.cooldowns.tick();
|
||||
// Options.broadcastOptions: a changed render distance or Chat option
|
||||
// is sent again.
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
@@ -3425,6 +3462,13 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Creeper {
|
||||
horizon_client::mobs::hostile::creeper_client_tick(m);
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
horizon_client::mobs::Species::Guardian
|
||||
| horizon_client::mobs::Species::ElderGuardian
|
||||
) {
|
||||
horizon_client::mobs::guardian::client_tick(m);
|
||||
}
|
||||
if m.hurt_time > 0 {
|
||||
m.hurt_time -= 1;
|
||||
}
|
||||
@@ -3523,6 +3567,18 @@ impl Game<'_> {
|
||||
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
|
||||
}
|
||||
if running {
|
||||
// The dragon: LivingEntity's interpolation, inside its aiStep.
|
||||
let dragon_lerp = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.and_then(|d| lerps.get_mut(&(d.id as i32)))
|
||||
.filter(|l| l.steps > 0)
|
||||
.map(|l| {
|
||||
let steps = l.steps;
|
||||
l.steps -= 1;
|
||||
(l.target, l.rotation.map(|r| r.0), steps)
|
||||
});
|
||||
self.tick_remote_dragon(dragon_lerp);
|
||||
// Boats, minecarts, armor stands, projectiles and the rest
|
||||
// interpolate like mobs.
|
||||
let mut step = |id: u32, at: Vec3| match lerps.get_mut(&(id as i32)) {
|
||||
@@ -4819,16 +4875,20 @@ impl Game<'_> {
|
||||
if self.open_menu_name(p.container) != "minecraft:merchant" {
|
||||
return;
|
||||
}
|
||||
let cost = |g: &Self, c: &play::NetCost| horizon_client::merchant::ItemCost {
|
||||
stack: ItemStack::new(&g.item_name(c.item), c.count.max(0) as u32),
|
||||
let cost = |g: &Self, c: &play::NetCost| {
|
||||
g.to_stack(&Some(c.clone().into()))
|
||||
.map(|stack| horizon_client::merchant::ItemCost { stack })
|
||||
};
|
||||
let offers: Vec<_> = p
|
||||
.offers
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let mut offer = horizon_client::merchant::MerchantOffer {
|
||||
base_cost_a: cost(self, &o.cost_a),
|
||||
cost_b: o.cost_b.as_ref().map(|b| cost(self, b)),
|
||||
base_cost_a: cost(self, &o.cost_a)?,
|
||||
cost_b: match &o.cost_b {
|
||||
Some(b) => Some(cost(self, b)?),
|
||||
None => None,
|
||||
},
|
||||
result: self.to_stack(&Some(o.result.clone()))?,
|
||||
uses: o.uses,
|
||||
max_uses: o.max_uses,
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, bat, tame};
|
||||
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_protocol::registry::Builtin;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
/// `builtin` names the ids of built-in registries.
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
@@ -46,6 +46,19 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
_ => {}
|
||||
}
|
||||
match (species, *index, value) {
|
||||
(Species::Guardian | Species::ElderGuardian, 16, Value::Bool(moving)) => {
|
||||
m.extra.flag2 = *moving;
|
||||
}
|
||||
(Species::Guardian | Species::ElderGuardian, 17, Value::Int(target)) => {
|
||||
// Guardian.onSyncedDataUpdated resets the visual charge clock.
|
||||
m.extra.counter2 = 0;
|
||||
m.extra.flag = *target != 0;
|
||||
m.ai.target = (*target != 0).then_some(if *target == local_entity {
|
||||
Who::Player
|
||||
} else {
|
||||
Who::Mob(*target as u32)
|
||||
});
|
||||
}
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0.
|
||||
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
|
||||
@@ -116,7 +129,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values, &r.builtin);
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +166,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
super::apply(m, values, &builtin());
|
||||
super::apply(m, values, &builtin(), 99);
|
||||
}
|
||||
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
@@ -164,6 +177,49 @@ mod tests {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_charge_follows_target_updates_and_caps_at_its_duration() {
|
||||
use horizon_client::mobs::guardian::{beam, client_tick};
|
||||
for (kind, duration) in [("guardian", 80), ("elder_guardian", 60)] {
|
||||
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
|
||||
return;
|
||||
};
|
||||
assert!(beam(&m, 0.0).is_none());
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(16, s::BOOLEAN, Value::Bool(true)),
|
||||
(17, s::INT, Value::Int(99)),
|
||||
],
|
||||
);
|
||||
assert!(m.extra.flag2);
|
||||
assert_eq!(m.ai.target, Some(Who::Player));
|
||||
assert_eq!(beam(&m, 0.0), Some((0.0, 0.0)));
|
||||
for _ in 0..duration + 10 {
|
||||
client_tick(&mut m);
|
||||
}
|
||||
assert_eq!(beam(&m, 0.0), Some((duration as f32, 1.0)));
|
||||
// Another player or a mob retains its network entity id.
|
||||
apply(&mut m, &[(17, s::INT, Value::Int(42))]);
|
||||
assert_eq!(m.ai.target, Some(Who::Mob(42)));
|
||||
assert_eq!(m.extra.counter2, 0);
|
||||
client_tick(&mut m);
|
||||
assert_eq!(m.extra.counter2, 1);
|
||||
apply(
|
||||
&mut m,
|
||||
&[
|
||||
(16, s::BOOLEAN, Value::Bool(false)),
|
||||
(17, s::INT, Value::Int(0)),
|
||||
],
|
||||
);
|
||||
client_tick(&mut m);
|
||||
assert!(!m.extra.flag2);
|
||||
assert_eq!(m.ai.target, None);
|
||||
assert_eq!(m.extra.counter2, 0);
|
||||
assert!(beam(&m, 0.0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
|
||||
let Some(mut m) = mob("minecraft:sheep") else {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `handleSetEquipment`): the server simulates them, the client places them
|
||||
//! where it says and interpolates their moves like a mob's.
|
||||
use super::Game;
|
||||
use horizon_client::entities::{ArmorStand, ItemFrame, Painting};
|
||||
use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use std::sync::Arc;
|
||||
@@ -57,6 +57,16 @@ impl Game<'_> {
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
});
|
||||
} else if kind == "minecraft:cushion" {
|
||||
// BlockAttachedEntity.pos: `BlockPos.containing` of the spawn.
|
||||
self.cushions.push(Cushion {
|
||||
id,
|
||||
position: at,
|
||||
pos: block,
|
||||
yaw: super::cushions::snap_yaw(yaw),
|
||||
color: 0,
|
||||
check_interval: 0,
|
||||
});
|
||||
} else if kind == "minecraft:lightning_bolt" {
|
||||
self.spawn_remote_bolt(id, position);
|
||||
} else if kind == "minecraft:painting" {
|
||||
@@ -152,6 +162,11 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if let Some(c) = self.cushions.iter_mut().find(|c| c.id == id) {
|
||||
// Cushion.DATA_COLOR (DyeColor, a byte-backed id).
|
||||
if let (8, Value::Id(color)) = (index, value) {
|
||||
c.color = (*color).clamp(0, 15) as u8;
|
||||
}
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
// VehicleEntity's hurt, direction and damage; the paddles.
|
||||
match (index, value) {
|
||||
@@ -267,6 +282,12 @@ impl Game<'_> {
|
||||
out.push(json!({"id": a.id, "kind": "minecraft:armor_stand", "position": at(a.position), "yaw": a.yaw,
|
||||
"small": a.small, "arms": a.show_arms, "no_base_plate": a.no_base_plate, "equipment": items}));
|
||||
}
|
||||
for c in &self.cushions {
|
||||
out.push(
|
||||
json!({"id": c.id, "kind": "minecraft:cushion", "position": at(c.position),
|
||||
"yaw": c.yaw, "color": c.color_name()}),
|
||||
);
|
||||
}
|
||||
for f in &self.item_frames {
|
||||
out.push(
|
||||
json!({"id": f.id, "kind": f.kind(), "position": f.pos, "facing": f.facing,
|
||||
@@ -286,6 +307,7 @@ impl Game<'_> {
|
||||
self.carts.clear();
|
||||
self.boats.clear();
|
||||
self.armor_stands.clear();
|
||||
self.cushions.clear();
|
||||
self.item_frames.clear();
|
||||
self.paintings.clear();
|
||||
}
|
||||
@@ -295,6 +317,7 @@ impl Game<'_> {
|
||||
self.carts.retain(|c| c.id != id);
|
||||
self.boats.retain(|b| b.id != id);
|
||||
self.armor_stands.retain(|a| a.id != id);
|
||||
self.cushions.retain(|c| c.id != id);
|
||||
self.item_frames.retain(|f| f.id != id);
|
||||
self.paintings.retain(|p| p.id != id);
|
||||
self.bolts.retain(|b| b.id != id);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -37,6 +37,32 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `AbstractArrow.tick` on a client: water drag before the move, air drag
|
||||
/// after it, then gravity; a block it reaches holds it (`inGround`).
|
||||
fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
if p.stuck {
|
||||
return;
|
||||
}
|
||||
let water = w.in_water(p.position);
|
||||
if water {
|
||||
p.velocity = p.velocity.scale(0.6);
|
||||
}
|
||||
let to = p.position.add_vector(p.velocity);
|
||||
if let Some((at, _, _)) = w.clip(p.position, to) {
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.stuck = true;
|
||||
return;
|
||||
}
|
||||
p.position = to;
|
||||
if !water {
|
||||
p.velocity = p.velocity.scale(0.99);
|
||||
}
|
||||
p.velocity.y -= 0.05;
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `handleAddEntity` for these types; false when `kind` isn't one.
|
||||
pub(super) fn spawn_remote_projectile(
|
||||
@@ -246,29 +272,34 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.falling = falling;
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
if matches!(p.kind, ProjectileKind::LargeFireball { .. }) {
|
||||
// AbstractHurtingProjectile.tick advances on the client between
|
||||
// the tracker updates (ten ticks apart for a ghast's fireball).
|
||||
let at = p.position;
|
||||
let water = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
p.apply_hurting_inertia(water);
|
||||
p.position = p.position.add_vector(p.velocity);
|
||||
} else {
|
||||
p.position = step(p.id, p.position);
|
||||
// Projectiles tick on the client too and fly on between the tracker's
|
||||
// updates (10 to 20 ticks apart); a block stops them there, and only
|
||||
// the server decides what they hit.
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
{
|
||||
let w = self.mob_world_base(&[]);
|
||||
let world = super::creatures::ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
};
|
||||
for p in &mut projectiles {
|
||||
match p.kind {
|
||||
ProjectileKind::Arrow { .. } => remote_arrow_tick(p, &world),
|
||||
// ShulkerBullet.tick: only the server steers it.
|
||||
ProjectileKind::ShulkerBullet { .. } => {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
p.position = p.position.add_vector(p.velocity);
|
||||
}
|
||||
_ => {
|
||||
if !matches!(p.tick(&world).1, Hit::None) {
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.creatures.projectiles = projectiles;
|
||||
for t in &mut self.tridents {
|
||||
let id = t.id;
|
||||
t.remote_step(|at| step(id, at));
|
||||
@@ -302,12 +333,9 @@ impl Game<'_> {
|
||||
c.time += 1;
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
self.potions.step_remote_thrown(&mut step);
|
||||
self.step_remote_thrown();
|
||||
self.step_remote_hooks(&mut step);
|
||||
self.end.step_remote_eyes(|id, at| {
|
||||
let at = step(id, Vec3::new(at[0], at[1], at[2]));
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
self.end.step_remote_eyes();
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
|
||||
@@ -330,6 +358,8 @@ impl Game<'_> {
|
||||
self.tridents.clear();
|
||||
self.rockets.clear();
|
||||
self.crystals.clear();
|
||||
// The dragon a server sent goes with its crystals.
|
||||
self.dragon = None;
|
||||
self.potions.clear_thrown();
|
||||
self.end.clear_remote_eyes();
|
||||
self.clear_remote_hooks();
|
||||
|
||||
@@ -56,6 +56,11 @@ impl Game<'_> {
|
||||
let p = a.position;
|
||||
hits.push((a.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if let Some((i, d, _)) = self.cushion_hit() {
|
||||
let c = &self.cushions[i];
|
||||
let p = c.position;
|
||||
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if let Some((i, d)) = self.boat_hit() {
|
||||
let p = self.boats[i].position;
|
||||
hits.push((self.boats[i].id as i32, d, [p.x, p.y, p.z]));
|
||||
@@ -79,6 +84,8 @@ impl Game<'_> {
|
||||
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
}
|
||||
// EnderDragonPart.isPickable: the server's dragon hurts by part.
|
||||
hits.extend(self.remote_dragon_part_hit(eye, dir, reach));
|
||||
}
|
||||
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let point = at(distance);
|
||||
@@ -138,7 +145,10 @@ impl Game<'_> {
|
||||
self.position_rider();
|
||||
return true;
|
||||
}
|
||||
let seat = if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let seat = if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
|
||||
// The seat does not move; the rider sits on the cushion.
|
||||
Some(c.seat())
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
// AbstractBoat.tick on the controlling client: steer and move.
|
||||
let mut b = self.boats[i].clone();
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Server mods in the game (`docs/mods.md`, "Server mods"): the game that
|
||||
//! simulates the world (in-frame single player, the server thread, a LAN
|
||||
//! host, the dedicated server) runs them. At the end of each tick it tells
|
||||
//! them who joined and left, then what players said, broke and placed since
|
||||
//! the last one, then ticks them; a player's command reaches them at once.
|
||||
//! Their lines go to the players they name. With no server mod on, each hook
|
||||
//! here is one check.
|
||||
use super::{Game, lan::LanHost};
|
||||
#[cfg(not(feature = "mods"))]
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// Who did something a server mod hears about, resolved at the tick's end.
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(super) enum Who {
|
||||
/// The window's own player, in-frame.
|
||||
Local,
|
||||
/// A connection's player (the server thread's owner is one).
|
||||
Conn(u32),
|
||||
Uuid(u128),
|
||||
}
|
||||
|
||||
/// What players did during a tick, for the server mods at its end.
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(super) enum ModEvent {
|
||||
Chat(Who, String),
|
||||
Block {
|
||||
who: Who,
|
||||
pos: [i32; 3],
|
||||
state: String,
|
||||
placed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_server_mods(&mut self) {}
|
||||
pub(super) fn mod_block_event(&mut self, _pos: [i32; 3], _state: BlockStateId, _placed: bool) {}
|
||||
pub(super) fn mod_player_chat(&mut self, _who: Who, _text: &str) {}
|
||||
pub(super) fn local_server_mod_command(&mut self, _line: &str) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn guest_server_mod_command(
|
||||
&mut self,
|
||||
_host: &mut LanHost,
|
||||
_conn: u32,
|
||||
_line: &str,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
mod run {
|
||||
use super::{Game, LanHost, ModEvent, Who};
|
||||
use horizon_client::{
|
||||
command::args::GameMode as Mode,
|
||||
lan::data::component_nbt,
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// `UUID.toString()`.
|
||||
fn uuid_string(u: u128) -> String {
|
||||
let h = format!("{u:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..]
|
||||
)
|
||||
}
|
||||
|
||||
fn mode(id: i32) -> GameMode {
|
||||
match id {
|
||||
1 => GameMode::Creative,
|
||||
2 => GameMode::Adventure,
|
||||
3 => GameMode::Spectator,
|
||||
_ => GameMode::Survival,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players on this game's server: the window's own player
|
||||
/// in-frame, then every connection's. `host` is the hub when it is
|
||||
/// out of `self.lan`.
|
||||
pub(in crate::game) fn server_players(&self, host: Option<&LanHost>) -> Vec<ServerPlayer> {
|
||||
let mut out = Vec::new();
|
||||
if self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& let Some(p) = &self.player
|
||||
{
|
||||
out.push(ServerPlayer {
|
||||
id: self.player_uuid_string(),
|
||||
name: crate::game::chat::PLAYER_NAME.to_owned(),
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
z: p.position.z,
|
||||
dimension: self.dim.id().to_owned(),
|
||||
game_mode: match self.mode {
|
||||
Mode::Survival => GameMode::Survival,
|
||||
Mode::Creative => GameMode::Creative,
|
||||
Mode::Adventure => GameMode::Adventure,
|
||||
Mode::Spectator => GameMode::Spectator,
|
||||
},
|
||||
health: p.health.health(),
|
||||
});
|
||||
}
|
||||
let guests = host
|
||||
.or(self.lan.as_ref())
|
||||
.into_iter()
|
||||
.flat_map(|h| &h.hub.guests);
|
||||
out.extend(guests.filter(|g| g.placed).map(|g| ServerPlayer {
|
||||
id: uuid_string(g.uuid),
|
||||
name: g.name.clone(),
|
||||
x: g.position[0],
|
||||
y: g.position[1],
|
||||
z: g.position[2],
|
||||
dimension: g.dimension.clone(),
|
||||
game_mode: mode(g.game_mode),
|
||||
health: g.health,
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether what players do now reaches server mods: one is running
|
||||
/// and this game simulates the world.
|
||||
fn hears_players(&self) -> bool {
|
||||
self.mods.server && self.remote.is_none()
|
||||
}
|
||||
|
||||
/// The tick's end: joins and leaves, the tick's events, the tick hook.
|
||||
pub(in crate::game) fn tick_server_mods(&mut self) {
|
||||
if !self.options.execution_role.is_client() {
|
||||
// A server has no frames to check its dev mods in.
|
||||
self.reload_dev_mods();
|
||||
}
|
||||
if !self.mods.server {
|
||||
return;
|
||||
}
|
||||
let players = self.server_players(None);
|
||||
let snapshot = self.mod_snapshot_with(players.clone());
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
let before = std::mem::take(&mut self.mods.players);
|
||||
for p in before
|
||||
.iter()
|
||||
.filter(|p| !players.iter().any(|q| q.id == p.id))
|
||||
{
|
||||
self.mods.mods.player_left(p);
|
||||
}
|
||||
for p in players
|
||||
.iter()
|
||||
.filter(|p| !before.iter().any(|q| q.id == p.id))
|
||||
{
|
||||
self.mods.mods.player_joined(p);
|
||||
}
|
||||
for event in std::mem::take(&mut self.mods.events) {
|
||||
let who = |w: &Who| {
|
||||
let id = match w {
|
||||
Who::Local => self.player_uuid_string(),
|
||||
Who::Uuid(u) => uuid_string(*u),
|
||||
Who::Conn(c) => {
|
||||
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
|
||||
let g = guests.into_iter().find(|g| g.conn == *c);
|
||||
g.map_or_else(String::new, |g| uuid_string(g.uuid))
|
||||
}
|
||||
};
|
||||
players.iter().chain(&before).find(|p| p.id == id).cloned()
|
||||
};
|
||||
match event {
|
||||
ModEvent::Chat(w, text) => {
|
||||
if let Some(p) = who(&w) {
|
||||
self.mods.mods.player_chat(&p, &text);
|
||||
}
|
||||
}
|
||||
ModEvent::Block {
|
||||
who: w,
|
||||
pos,
|
||||
state,
|
||||
placed,
|
||||
} => {
|
||||
let Some(p) = who(&w) else { continue };
|
||||
let pos = BlockPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
};
|
||||
if placed {
|
||||
self.mods.mods.block_placed(&p, pos, &state);
|
||||
} else {
|
||||
self.mods.mods.block_broken(&p, pos, &state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mods.mods.server_tick(self.ticks);
|
||||
self.mods.players = players;
|
||||
self.show_mod_output();
|
||||
}
|
||||
|
||||
/// A player broke (or placed) `state` at `pos`: the player acting now.
|
||||
pub(in crate::game) fn mod_block_event(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
placed: bool,
|
||||
) {
|
||||
if !self.hears_players() {
|
||||
return;
|
||||
}
|
||||
let who = self.lan_acting.map_or(Who::Local, Who::Conn);
|
||||
let state = self.state_string(state);
|
||||
self.mods.events.push(ModEvent::Block {
|
||||
who,
|
||||
pos,
|
||||
state,
|
||||
placed,
|
||||
});
|
||||
}
|
||||
|
||||
/// A chat message a player sent.
|
||||
pub(in crate::game) fn mod_player_chat(&mut self, who: Who, text: &str) {
|
||||
if self.hears_players() {
|
||||
self.mods.events.push(ModEvent::Chat(who, text.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
/// `/line` typed by the window's own player in-frame: true when a
|
||||
/// server mod owns it.
|
||||
pub(in crate::game) fn local_server_mod_command(&mut self, line: &str) -> bool {
|
||||
if !self.hears_players() {
|
||||
return false;
|
||||
}
|
||||
let players = self.server_players(None);
|
||||
let Some(me) = players.first().cloned() else {
|
||||
return false;
|
||||
};
|
||||
let snapshot = self.mod_snapshot_with(players);
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
let owned = self.mods.mods.server_command(&me, line);
|
||||
self.show_mod_output();
|
||||
owned
|
||||
}
|
||||
|
||||
/// `/line` typed by a connection's player: true when a server mod
|
||||
/// owns it, whatever the player's permission level.
|
||||
pub(in crate::game) fn guest_server_mod_command(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
line: &str,
|
||||
) -> bool {
|
||||
if !self.hears_players() {
|
||||
return false;
|
||||
}
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return false;
|
||||
};
|
||||
let id = uuid_string(g.uuid);
|
||||
let players = self.server_players(Some(host));
|
||||
let Some(me) = players.iter().find(|p| p.id == id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let snapshot = self.mod_snapshot_with(players);
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
if !self.mods.mods.server_command(&me, line) {
|
||||
return false;
|
||||
}
|
||||
self.route_mod_output(Some(host));
|
||||
true
|
||||
}
|
||||
|
||||
/// Sends a server mod's line to the player with this id: the
|
||||
/// window's own chat for the local player, a system message for a
|
||||
/// connection's. `host` is the hub when it is out of `self.lan`.
|
||||
pub(in crate::game) fn tell_server_player(
|
||||
&mut self,
|
||||
host: Option<&mut LanHost>,
|
||||
id: &str,
|
||||
line: Text,
|
||||
) {
|
||||
let local = self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& self.player.is_some();
|
||||
if local && self.player_uuid_string() == id {
|
||||
self.add_mod_chat_line(line);
|
||||
return;
|
||||
}
|
||||
let Some(host) = host.or(self.lan.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let to = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && uuid_string(g.uuid) == id);
|
||||
if let Some(conn) = to.map(|g| g.conn) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a server mod's output: `Tell` and `Reply` to their player,
|
||||
/// `Broadcast` to everyone and the log.
|
||||
pub(in crate::game) fn route_server_output(
|
||||
&mut self,
|
||||
mut host: Option<&mut LanHost>,
|
||||
output: Output,
|
||||
) {
|
||||
let named = |name: &str, text: String| {
|
||||
let mut line = Text::literal(format!("[{name}] ")).with_color(text::GRAY);
|
||||
line.push(Text::literal(text));
|
||||
line
|
||||
};
|
||||
match output {
|
||||
Output::Tell { name, player, text } => {
|
||||
self.tell_server_player(host, &player, named(&name, text));
|
||||
}
|
||||
Output::Reply {
|
||||
player,
|
||||
text,
|
||||
error,
|
||||
} => {
|
||||
let line = Text::literal(text);
|
||||
let line = if error {
|
||||
line.with_color(text::RED)
|
||||
} else {
|
||||
line
|
||||
};
|
||||
self.tell_server_player(host, &player, line);
|
||||
}
|
||||
Output::Broadcast { name, text } => {
|
||||
let plain = format!("[{name}] {text}");
|
||||
if self.options.dedicated.is_some() {
|
||||
super::super::dedicated::log("INFO", &plain);
|
||||
} else {
|
||||
log::info!("{plain}");
|
||||
}
|
||||
for p in self.server_players(host.as_deref()) {
|
||||
let line = named(&name, text.clone());
|
||||
self.tell_server_player(host.as_deref_mut(), &p.id, line);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use super::respawn_anchor::RespawnConfig;
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::{lan::hub::TickCtx, text::Text};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A trip under way: the player is in the destination's level, whose
|
||||
/// chunks are loading around `target`.
|
||||
@@ -103,8 +104,35 @@ impl Game<'_> {
|
||||
if let Some(pos) = self.nether_portal_inside(at) {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
}
|
||||
// `ServerPlayer.showEndCredits`: the player stays out of the world while the
|
||||
// poem rolls, then PERFORM_RESPAWN takes it to its respawn point.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.won_game {
|
||||
if std::mem::take(&mut play.respawn_requested) {
|
||||
play.won_game = false;
|
||||
self.start_server_trip(host, conn, Dim::Overworld, None);
|
||||
self.portals.player.cooldown = 10;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
if end_portal {
|
||||
// EndPortalBlock.entityInside: the first exit from the End shows the credits.
|
||||
if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) {
|
||||
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) {
|
||||
g.seen_credits = true;
|
||||
}
|
||||
host.play.entry(conn).or_default().won_game = true;
|
||||
// ClientboundGameEventPacket.WIN_GAME.
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::GameEvent(play::GameEvent {
|
||||
event: 4,
|
||||
param: 0.0,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
|
||||
@@ -6,9 +6,9 @@ use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
signs::wood_type,
|
||||
ui::EditBox,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -18,7 +18,16 @@ pub(super) struct SignEditor {
|
||||
pub pos: (i32, i32, i32),
|
||||
pub front: bool,
|
||||
pub line: usize,
|
||||
pub opened: Instant,
|
||||
/// The line being edited (`AbstractSignEditScreen`'s text field).
|
||||
edit: EditBox,
|
||||
}
|
||||
|
||||
fn sign_box() -> EditBox {
|
||||
let mut edit = EditBox::new(0.0, 0.0, 90.0, 9.0);
|
||||
edit.bordered = false;
|
||||
edit.max_length = 384;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -28,8 +37,9 @@ impl Game<'_> {
|
||||
pos,
|
||||
front,
|
||||
line: 0,
|
||||
opened: Instant::now(),
|
||||
edit: sign_box(),
|
||||
});
|
||||
self.sign_load();
|
||||
self.keys.clear();
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
@@ -140,97 +150,154 @@ impl Game<'_> {
|
||||
/// A key and the text it typed on the sign being edited (the keyboard,
|
||||
/// or a controller's on-screen keyboard).
|
||||
pub(super) fn sign_input(&mut self, key: Option<KeyCode>, typed: Option<&str>) {
|
||||
let Some(edit) = self.sign_edit.as_mut() else {
|
||||
if self.sign_edit.is_none() {
|
||||
return;
|
||||
};
|
||||
let front = edit.front;
|
||||
let id = sign_line_id(front, edit.line);
|
||||
}
|
||||
match key {
|
||||
Some(KeyCode::Escape) => self.close_sign_editor(),
|
||||
Some(KeyCode::ArrowUp) => edit.line = (edit.line + 3) & 3,
|
||||
Some(KeyCode::ArrowUp) => self.sign_move_line(3),
|
||||
Some(KeyCode::ArrowDown | KeyCode::Enter | KeyCode::NumpadEnter) => {
|
||||
edit.line = (edit.line + 1) & 3
|
||||
self.sign_move_line(1)
|
||||
}
|
||||
Some(KeyCode::Backspace) => {
|
||||
let (line, key) = (edit.line, edit.pos);
|
||||
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
|
||||
let text = if front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
let value = &mut text.lines[line];
|
||||
if !self.text_selection.take(id, value) {
|
||||
value.pop();
|
||||
}
|
||||
}
|
||||
Some(
|
||||
key @ (KeyCode::ArrowLeft
|
||||
| KeyCode::ArrowRight
|
||||
| KeyCode::Home
|
||||
| KeyCode::End
|
||||
| KeyCode::Backspace
|
||||
| KeyCode::Delete),
|
||||
) => {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
self.sign_change(|edit| {
|
||||
edit.key(key, ctrl, shift);
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let Some(typed) = typed else {
|
||||
return;
|
||||
};
|
||||
let line = edit.line;
|
||||
let atlas = &self.gui.atlas;
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s);
|
||||
let Some(key) = self.sign_edit.as_ref().map(|e| e.pos) else {
|
||||
return;
|
||||
};
|
||||
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
|
||||
let max = sign.max_line_width();
|
||||
let text = if front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
self.text_selection.take(id, &mut text.lines[line]);
|
||||
for ch in typed.chars().filter(|c| !c.is_control()) {
|
||||
let mut next = text.lines[line].clone();
|
||||
next.push(ch);
|
||||
// TextFieldHelper filter: font.width(s) <= maxTextLineWidth.
|
||||
if width(&next) <= max {
|
||||
text.lines[line] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sign_change(|edit| edit.insert_text(typed));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line: a paste
|
||||
/// (formatting and carriage returns removed) is taken whole or not at
|
||||
/// all, as the line must stay within the sign's width.
|
||||
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
/// Up or down a line: the box writes the line it leaves and loads the next.
|
||||
fn sign_move_line(&mut self, by: usize) {
|
||||
self.sign_write();
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.line = (edit.line + by) & 3;
|
||||
}
|
||||
self.sign_load();
|
||||
}
|
||||
/// The edited line's text.
|
||||
fn sign_current(&self) -> Option<String> {
|
||||
let edit = self.sign_edit.as_ref()?;
|
||||
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
|
||||
return None;
|
||||
};
|
||||
let side = if edit.front { &sign.front } else { &sign.back };
|
||||
Some(side.lines[edit.line].clone())
|
||||
}
|
||||
/// `EditBox.setValue` from the line now being edited.
|
||||
fn sign_load(&mut self) {
|
||||
let Some(text) = self.sign_current() else {
|
||||
return;
|
||||
};
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.edit.set_value(&text);
|
||||
}
|
||||
}
|
||||
/// The box's text onto the sign, when it still fits the line.
|
||||
fn sign_write(&mut self) {
|
||||
let Some(edit) = self.sign_edit.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (key, front, line) = (edit.pos, edit.front, edit.line);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
|
||||
let (pos, front, line, value) = (edit.pos, edit.front, edit.line, edit.edit.value.clone());
|
||||
if !self.sign_fits(&value) {
|
||||
return;
|
||||
}
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return;
|
||||
};
|
||||
let max = sign.max_line_width();
|
||||
let text = if front {
|
||||
let side = if front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
self.text_selection.apply(
|
||||
sign_line_id(front, line),
|
||||
&mut text.lines[line],
|
||||
shortcut,
|
||||
&mut self.clipboard,
|
||||
|value, pasted| {
|
||||
let next = format!(
|
||||
"{value}{}",
|
||||
horizon_client::clipboard::helper_clipboard_contents(pasted)
|
||||
);
|
||||
HudBatch::text_width(atlas, &next) <= max && {
|
||||
*value = next;
|
||||
true
|
||||
}
|
||||
},
|
||||
);
|
||||
side.lines[line] = value;
|
||||
}
|
||||
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20).
|
||||
/// TextFieldHelper: `font.width(s) <= maxTextLineWidth`.
|
||||
fn sign_fits(&self, value: &str) -> bool {
|
||||
let Some(edit) = self.sign_edit.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) else {
|
||||
return false;
|
||||
};
|
||||
HudBatch::text_width(&self.gui.atlas, value) <= sign.max_line_width()
|
||||
}
|
||||
/// Edit the line, then keep the result only when it still fits.
|
||||
fn sign_change(&mut self, f: impl FnOnce(&mut EditBox)) {
|
||||
let before = self
|
||||
.sign_edit
|
||||
.as_ref()
|
||||
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
f(&mut edit.edit);
|
||||
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
|
||||
if clean != edit.edit.value {
|
||||
let n = horizon_client::ui::char_len(&clean);
|
||||
edit.edit.value = clean;
|
||||
edit.edit.cursor = edit.edit.cursor.min(n);
|
||||
edit.edit.highlight = edit.edit.highlight.min(n);
|
||||
}
|
||||
}
|
||||
self.sign_keep(before);
|
||||
}
|
||||
/// Write the box when the line still fits; otherwise put the old text back.
|
||||
fn sign_keep(&mut self, before: Option<(String, usize, usize)>) {
|
||||
let value = self
|
||||
.sign_edit
|
||||
.as_ref()
|
||||
.map(|e| e.edit.value.clone())
|
||||
.unwrap_or_default();
|
||||
if self.sign_fits(&value) {
|
||||
self.sign_write();
|
||||
} else if let (Some(edit), Some((value, cursor, highlight))) =
|
||||
(self.sign_edit.as_mut(), before)
|
||||
{
|
||||
edit.edit.value = value;
|
||||
edit.edit.cursor = cursor;
|
||||
edit.edit.highlight = highlight;
|
||||
}
|
||||
}
|
||||
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line. Paste
|
||||
/// reads `getClipboardContents` before `insertText`. A paste that would
|
||||
/// pass the sign's width is refused whole.
|
||||
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
if shortcut == horizon_client::clipboard::EditShortcut::Paste {
|
||||
let raw = horizon_client::clipboard::Clipboard::get(&mut self.clipboard);
|
||||
let text = horizon_client::clipboard::helper_clipboard_contents(&raw);
|
||||
self.sign_change(|edit| edit.insert_text(&text));
|
||||
return;
|
||||
}
|
||||
let before = self
|
||||
.sign_edit
|
||||
.as_ref()
|
||||
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.edit.shortcut(shortcut, &mut self.clipboard);
|
||||
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
|
||||
if clean != edit.edit.value {
|
||||
let n = horizon_client::ui::char_len(&clean);
|
||||
edit.edit.value = clean;
|
||||
edit.edit.cursor = edit.edit.cursor.min(n);
|
||||
edit.edit.highlight = edit.edit.highlight.min(n);
|
||||
}
|
||||
}
|
||||
self.sign_keep(before);
|
||||
}
|
||||
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20),
|
||||
/// or a click on a line (`EditBox.onClick`).
|
||||
pub(super) fn sign_edit_click(&mut self, size: [u32; 2]) {
|
||||
let b = HudBatch::new(size);
|
||||
let (x, y) = (b.width / 2.0 - 100.0, b.height / 4.0 + 144.0);
|
||||
@@ -238,7 +305,56 @@ impl Game<'_> {
|
||||
if mx >= x && mx < x + 200.0 && my >= y && my < y + 20.0 {
|
||||
self.audio.play_button_click();
|
||||
self.close_sign_editor();
|
||||
return;
|
||||
}
|
||||
let Some((cx, top, line_height, max_width, lines)) = self.sign_lines(b.width) else {
|
||||
return;
|
||||
};
|
||||
let shift = self.has_shift_down();
|
||||
for i in 0..lines.len() {
|
||||
let ly = top + i as f32 * line_height;
|
||||
let left = cx - max_width / 2.0;
|
||||
if mx < left || mx >= left + max_width || my < ly || my >= ly + line_height {
|
||||
continue;
|
||||
}
|
||||
let current = self.sign_edit.as_ref().map(|e| e.line);
|
||||
if current != Some(i) {
|
||||
self.sign_move_line((i + 4 - current.unwrap_or(0)) & 3);
|
||||
}
|
||||
let value = self
|
||||
.sign_edit
|
||||
.as_ref()
|
||||
.map(|e| e.edit.value.clone())
|
||||
.unwrap_or_default();
|
||||
let lw = HudBatch::text_width(&self.gui.atlas, &value);
|
||||
let text_x = cx - (lw as i32 / 2) as f32;
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.edit.x = text_x;
|
||||
edit.edit.y = ly;
|
||||
edit.edit.width = max_width;
|
||||
edit.edit.click(&self.gui.atlas, mx, shift, false);
|
||||
}
|
||||
self.sign_write();
|
||||
return;
|
||||
}
|
||||
}
|
||||
/// Centre x, the first line's y, the line height, the width limit and the four lines.
|
||||
fn sign_lines(&self, width: f32) -> Option<(f32, f32, f32, f32, Vec<String>)> {
|
||||
let edit = self.sign_edit.as_ref()?;
|
||||
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
|
||||
return None;
|
||||
};
|
||||
let text = if edit.front { &sign.front } else { &sign.back };
|
||||
let line_height = sign.line_height();
|
||||
let y = if sign.hanging { 125.0 } else { 90.0 };
|
||||
let top = y - (4.0 * line_height / 2.0).floor();
|
||||
Some((
|
||||
width / 2.0,
|
||||
top,
|
||||
line_height,
|
||||
sign.max_line_width(),
|
||||
text.lines.to_vec(),
|
||||
))
|
||||
}
|
||||
pub(super) fn draw_sign_editor(&self, batch: &mut HudBatch) {
|
||||
let Some(edit) = &self.sign_edit else { return };
|
||||
@@ -304,23 +420,48 @@ impl Game<'_> {
|
||||
let color = rgba((color >> 16) as u8, (color >> 8) as u8, color as u8, 255);
|
||||
let line_height = sign.line_height();
|
||||
let midpoint = (4.0 * line_height / 2.0).floor();
|
||||
let blink = (edit.opened.elapsed().as_millis() / 300) % 2 == 0;
|
||||
for (i, line) in text.lines.iter().enumerate() {
|
||||
let lw = HudBatch::text_width(atlas, line);
|
||||
let shown = if i == edit.line {
|
||||
edit.edit.value.as_str()
|
||||
} else {
|
||||
line.as_str()
|
||||
};
|
||||
let lw = HudBatch::text_width(atlas, shown);
|
||||
let x = cx - (lw as i32 / 2) as f32;
|
||||
let ly = y + i as f32 * line_height - midpoint;
|
||||
batch.text(atlas, line, x, ly, color, false);
|
||||
if i == edit.line
|
||||
&& self
|
||||
.text_selection
|
||||
.covers(sign_line_id(edit.front, i), line)
|
||||
{
|
||||
// AbstractSignEditScreen: the selection from its start to its end.
|
||||
batch.text_highlight(atlas, (x, ly, lw, line_height), true);
|
||||
batch.text(atlas, shown, x, ly, color, false);
|
||||
if i != edit.line {
|
||||
continue;
|
||||
}
|
||||
if i == edit.line && blink {
|
||||
// TextCursorUtils.extractAppendCursor: an underscore after the text.
|
||||
batch.text(atlas, "_", x + lw, ly, color, false);
|
||||
let value = &edit.edit.value;
|
||||
if edit.edit.cursor != edit.edit.highlight {
|
||||
let (a, b) = (
|
||||
edit.edit.cursor.min(edit.edit.highlight),
|
||||
edit.edit.cursor.max(edit.edit.highlight),
|
||||
);
|
||||
let pre: String = value.chars().take(a).collect();
|
||||
let sel: String = value.chars().skip(a).take(b - a).collect();
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(
|
||||
x + HudBatch::text_width(atlas, &pre),
|
||||
ly,
|
||||
HudBatch::text_width(atlas, &sel),
|
||||
line_height,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
if edit.edit.cursor_visible() {
|
||||
let pre: String = value.chars().take(edit.edit.cursor).collect();
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + HudBatch::text_width(atlas, &pre),
|
||||
ly,
|
||||
1.0,
|
||||
line_height,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
let (bx, by) = (w / 2.0 - 100.0, h / 4.0 + 144.0);
|
||||
@@ -344,8 +485,3 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign line being edited, for Select All.
|
||||
fn sign_line_id(front: bool, line: usize) -> horizon_client::clipboard::FieldId {
|
||||
(if front { "sign.front" } else { "sign.back" }, line)
|
||||
}
|
||||
@@ -398,8 +398,12 @@ impl GameLevel<'_> {
|
||||
}
|
||||
}
|
||||
PreRemove::Campfire => {
|
||||
for _ in 0..4 {
|
||||
self.drop_item_stack(pos, None);
|
||||
let items = match &entity {
|
||||
Some(BlockEntity::Campfire(c)) => c.items.clone(),
|
||||
_ => Default::default(),
|
||||
};
|
||||
for stack in items {
|
||||
self.drop_item_stack(pos, stack);
|
||||
}
|
||||
}
|
||||
PreRemove::Jukebox => {
|
||||
|
||||
@@ -135,8 +135,10 @@ pub(super) struct Station {
|
||||
pub(super) offers: EnchantOffers,
|
||||
bookcases: i32,
|
||||
pub(super) anvil: AnvilResult,
|
||||
/// Anvil rename box text.
|
||||
/// Anvil rename box text (`AnvilMenu.itemName`).
|
||||
pub(super) name: String,
|
||||
/// The box itself (`AnvilScreen`'s `EditBox` of 50, unbordered).
|
||||
pub(super) rename: horizon_client::ui::EditBox,
|
||||
/// Stonecutter: the menu's recipe list (horizon_client::menu), its
|
||||
/// `selectedRecipeIndex` and the screen's first shown index.
|
||||
pub(super) stone: horizon_client::menu::Stonecutter,
|
||||
@@ -146,8 +148,6 @@ pub(super) struct Station {
|
||||
anvil_last: Option<ItemStack>,
|
||||
/// Anvil on a server: the name last sent (the client menu's `itemName`).
|
||||
pub(super) anvil_sent: Option<String>,
|
||||
/// When the screen opened: the rename box has the focus from then.
|
||||
opened: std::time::Instant,
|
||||
pub(super) last_sound: u64,
|
||||
/// Villager trading state.
|
||||
pub(super) merchant: Option<Box<super::trading::MerchantState>>,
|
||||
@@ -169,12 +169,12 @@ impl Station {
|
||||
bookcases: 0,
|
||||
anvil: AnvilResult::default(),
|
||||
name: String::new(),
|
||||
rename: anvil_box(),
|
||||
stone: Default::default(),
|
||||
selected: None,
|
||||
start: 0,
|
||||
anvil_last: None,
|
||||
anvil_sent: None,
|
||||
opened: std::time::Instant::now(),
|
||||
last_sound: 0,
|
||||
merchant: None,
|
||||
beacon: [None; 2],
|
||||
@@ -868,6 +868,9 @@ impl Game<'_> {
|
||||
if kind == Kind::Stonecutter && self.stonecutter_click(local) {
|
||||
return true;
|
||||
}
|
||||
if kind == Kind::Anvil && !right && self.anvil_name_click(ox, oy) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
|
||||
return true;
|
||||
@@ -894,6 +897,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
|
||||
.unwrap_or_default();
|
||||
let name = s.name.clone();
|
||||
s.rename.set_value(&name);
|
||||
}
|
||||
self.station_changed();
|
||||
}
|
||||
@@ -960,21 +965,29 @@ impl Game<'_> {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let cleared = self.text_selection.take(ANVIL_NAME, &mut s.name);
|
||||
if backspace {
|
||||
if !cleared {
|
||||
s.name.pop();
|
||||
}
|
||||
s.rename.delete_chars(-1);
|
||||
} else if let Some(t) = text {
|
||||
for c in t.chars().filter(|c| !c.is_control()) {
|
||||
if s.name.chars().count() < ANVIL_NAME_CHARS {
|
||||
s.name.push(c);
|
||||
}
|
||||
}
|
||||
s.rename.insert_text(t);
|
||||
}
|
||||
s.name.clone_from(&s.rename.value);
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
/// `AnvilScreen.keyPressed`: the rename box handles the key.
|
||||
pub(super) fn station_key(&mut self, key: winit::keyboard::KeyCode) -> bool {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if !s.rename.key(key, ctrl, shift) {
|
||||
return false;
|
||||
}
|
||||
s.name.clone_from(&s.rename.value);
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
true
|
||||
}
|
||||
/// Ctrl+A/C/V/X in the anvil's rename box (an `EditBox` of 50) or the
|
||||
/// recipe book's search box.
|
||||
pub(super) fn station_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
@@ -985,18 +998,52 @@ impl Game<'_> {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
ANVIL_NAME,
|
||||
&mut s.name,
|
||||
shortcut,
|
||||
ANVIL_NAME_CHARS,
|
||||
) {
|
||||
if s.rename.shortcut(shortcut, &mut self.clipboard) {
|
||||
s.name.clone_from(&s.rename.value);
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
}
|
||||
/// A click in the anvil's name box (`EditBox.onClick`).
|
||||
fn anvil_name_click(&mut self, ox: f32, oy: f32) -> bool {
|
||||
let open = self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == Kind::Anvil && s.inputs[0].is_some());
|
||||
if !open {
|
||||
return false;
|
||||
}
|
||||
let (x, y, w, h) = (ox + 62.0, oy + 24.0, 103.0, 12.0);
|
||||
let (mx, my) = self.cursor;
|
||||
if mx < x || my < y || mx >= x + w || my >= y + h {
|
||||
return false;
|
||||
}
|
||||
let shift = self.has_shift_down();
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
s.rename.x = x;
|
||||
s.rename.y = y;
|
||||
s.rename.width = w;
|
||||
s.rename.click(&self.gui.atlas, mx, shift, false);
|
||||
true
|
||||
}
|
||||
/// AnvilScreen's name box, drawn after the station picture.
|
||||
fn draw_anvil_name(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let edit = &mut s.rename;
|
||||
edit.x = ox + 62.0;
|
||||
edit.y = oy + 24.0;
|
||||
edit.width = 103.0;
|
||||
edit.height = 12.0;
|
||||
edit.bordered = false;
|
||||
edit.focused = true;
|
||||
edit.text_color = u32::MAX;
|
||||
edit.max_length = ANVIL_NAME_CHARS;
|
||||
edit.draw(batch, &self.gui.atlas, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drawing
|
||||
|
||||
@@ -1094,6 +1141,10 @@ impl Game<'_> {
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
|
||||
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
|
||||
let mut anvil_name = false;
|
||||
// Keep item tooltips as stacks until drawing: text alone loses the
|
||||
// bundle contents component (including in the player's inventory).
|
||||
let mut tooltip_stack: Option<ItemStack> = None;
|
||||
{
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.fill(
|
||||
@@ -1428,18 +1479,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
if s.inputs[0].is_some() {
|
||||
// AnvilScreen's name box: white, 103 wide, no inverted highlight.
|
||||
horizon_client::ui::append_field(
|
||||
batch,
|
||||
atlas,
|
||||
&s.name,
|
||||
(ox + 62.0, oy + 24.0, 103.0),
|
||||
(u32::MAX, true),
|
||||
ANVIL_NAME_CHARS,
|
||||
self.text_selection.covers(ANVIL_NAME, &s.name),
|
||||
false,
|
||||
s.opened.elapsed().as_millis(),
|
||||
);
|
||||
// Drawn once this borrow of the station ends.
|
||||
anvil_name = true;
|
||||
}
|
||||
let cost = s.anvil.cost;
|
||||
if cost > 0 {
|
||||
@@ -1579,13 +1620,16 @@ impl Game<'_> {
|
||||
);
|
||||
self.draw_item(batch, &result, ox + x, oy + y);
|
||||
if hover {
|
||||
tooltip = Some(self.stack_tooltip(&result));
|
||||
tooltip_stack = Some(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if anvil_name {
|
||||
self.draw_anvil_name(batch, ox, oy);
|
||||
}
|
||||
let drag = self.drag_view();
|
||||
for i in self.station_slots().unwrap_or_default() {
|
||||
let Some((sx, sy)) = self.slot_position_any(i) else {
|
||||
@@ -1606,13 +1650,16 @@ impl Game<'_> {
|
||||
);
|
||||
if self.carried.is_none()
|
||||
&& tooltip.is_none()
|
||||
&& tooltip_stack.is_none()
|
||||
&& let Some(stack) = self.slot_mut(slot).clone()
|
||||
{
|
||||
tooltip = Some(self.stack_tooltip(&stack));
|
||||
tooltip_stack = Some(stack);
|
||||
}
|
||||
}
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
} else if let Some(stack) = tooltip_stack {
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(lines) = tooltip {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
@@ -1656,6 +1703,8 @@ impl Game<'_> {
|
||||
};
|
||||
let name = if let Some(n) = stack.custom_name() {
|
||||
n.to_owned()
|
||||
} else if let Some(n) = stack.item_name() {
|
||||
n
|
||||
} else if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
|
||||
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
|
||||
@@ -1682,7 +1731,9 @@ impl Game<'_> {
|
||||
// ItemContainerContents.addToTooltip.
|
||||
let hover_name = |s: &ItemStack| {
|
||||
s.custom_name()
|
||||
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| s.item_name())
|
||||
.unwrap_or_else(|| self.pack.names.display_item(&s.item))
|
||||
};
|
||||
if display.shows("minecraft:container") {
|
||||
for text in
|
||||
@@ -1907,32 +1958,65 @@ impl Game<'_> {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lines: Vec<_> = lines
|
||||
let lines = Self::resolved(lines);
|
||||
let atlas = &self.gui.atlas;
|
||||
let (x, y, w, h) =
|
||||
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), 0.0);
|
||||
Self::tooltip_box(batch, atlas, x, y, w, h);
|
||||
Self::tooltip_text(batch, atlas, &lines, x, y);
|
||||
}
|
||||
/// A tooltip's lines with their legacy codes resolved and their colours.
|
||||
fn resolved<T: Clone + Into<horizon_client::text::Text>>(
|
||||
lines: &[(T, u32)],
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|(text, color)| {
|
||||
let text: horizon_client::text::Text = text.clone().into();
|
||||
(text.resolve_legacy(), *color)
|
||||
})
|
||||
.collect();
|
||||
let atlas = &self.gui.atlas;
|
||||
.collect()
|
||||
}
|
||||
/// The tooltip's text's top-left corner and the box's size: beside the
|
||||
/// cursor, kept on screen, `extra` pixels taller than the text.
|
||||
fn tooltip_geometry(
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
lines: &[(horizon_client::text::Text, u32)],
|
||||
(x, y): (f32, f32),
|
||||
(width, height): (f32, f32),
|
||||
extra: f32,
|
||||
) -> (f32, f32, f32, f32) {
|
||||
let w = lines
|
||||
.iter()
|
||||
.map(|(t, _)| atlas.font.width_text(t))
|
||||
.fold(0.0, f32::max);
|
||||
let h = if lines.len() == 1 {
|
||||
.fold(0.0, f32::max)
|
||||
.max(if extra > 0.0 { 96.0 } else { 0.0 });
|
||||
let text_h = if lines.len() == 1 {
|
||||
8.0
|
||||
} else {
|
||||
8.0 + 2.0 + (lines.len() - 1) as f32 * 10.0
|
||||
};
|
||||
let h = text_h + extra;
|
||||
let mut x = x + 12.0;
|
||||
let mut y = y - 12.0;
|
||||
if x + w > batch.width - 4.0 {
|
||||
if x + w > width - 4.0 {
|
||||
x = (x - 28.0 - w).max(4.0);
|
||||
}
|
||||
if y + h + 3.0 > batch.height {
|
||||
y = batch.height - h - 3.0;
|
||||
if y + h + 3.0 > height {
|
||||
y = height - h - 3.0;
|
||||
}
|
||||
y = y.max(4.0);
|
||||
(x, y, w, h)
|
||||
}
|
||||
/// The box and its frame sprites.
|
||||
fn tooltip_box(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
) {
|
||||
batch.fill(atlas, x - 3.0, y - 4.0, w + 6.0, 1.0, rgba(16, 0, 16, 240));
|
||||
batch.fill(
|
||||
atlas,
|
||||
@@ -1968,12 +2052,137 @@ impl Game<'_> {
|
||||
h + 4.0,
|
||||
rgba(60, 0, 190, 80),
|
||||
);
|
||||
}
|
||||
fn tooltip_text(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
lines: &[(horizon_client::text::Text, u32)],
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let mut ty = y;
|
||||
for (i, (text, color)) in lines.iter().enumerate() {
|
||||
batch.text_styled(atlas, text, x, ty, *color, true);
|
||||
ty += if i == 0 { 12.0 } else { 10.0 };
|
||||
}
|
||||
}
|
||||
/// A stack's tooltip: its lines, and under them a bundle's contents
|
||||
/// (`ClientBundleTooltip`: the 4 wide grid, the `+N` of what it leaves
|
||||
/// out, and the weight bar).
|
||||
pub(super) fn draw_stack_tooltip(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let lines = Self::resolved(&self.stack_tooltip(stack));
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The component's height does not depend on where it is drawn, so a
|
||||
// probe at the origin gives the box its extra height.
|
||||
let probe = horizon_client::bundle::tooltip_layout(self.res.items(), stack, 0.0, 0.0, 96.0);
|
||||
let extra = probe.as_ref().map_or(0.0, |l| l.height + 2.0);
|
||||
let atlas = &self.gui.atlas;
|
||||
let (x, y, w, h) =
|
||||
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), extra);
|
||||
Self::tooltip_box(batch, atlas, x, y, w, h);
|
||||
let text_h = h - extra;
|
||||
Self::tooltip_text(batch, atlas, &lines, x, y);
|
||||
let Some(layout) =
|
||||
horizon_client::bundle::tooltip_layout(self.res.items(), stack, x, y + text_h + 2.0, w)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.draw_bundle_tooltip(batch, &layout);
|
||||
}
|
||||
/// `ClientBundleTooltip.extractImage`: the slots, the count past them and
|
||||
/// the progress bar.
|
||||
fn draw_bundle_tooltip(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
layout: &horizon_client::bundle::TooltipLayout,
|
||||
) {
|
||||
for (at, stack) in &layout.slots {
|
||||
// Each draw takes the atlas on its own: the item's needs `&mut self`.
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
"gui/sprites/container/bundle/slot_background",
|
||||
at[0],
|
||||
at[1],
|
||||
24.0,
|
||||
24.0,
|
||||
u32::MAX,
|
||||
);
|
||||
let stack = stack.clone();
|
||||
let [x, y] = *at;
|
||||
self.draw_item(batch, &stack, x + 4.0, y + 4.0);
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if let Some((at, hidden)) = layout.overflow {
|
||||
let label = horizon_client::text::Text::literal(format!("+{hidden}"));
|
||||
let w = atlas.font.width_text(&label);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&label,
|
||||
at[0] + 12.0 - (w / 2.0).floor(),
|
||||
at[1] + 10.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if let Some((at, description)) = layout
|
||||
.empty_description
|
||||
.zip(Some("item.minecraft.bundle.empty.description"))
|
||||
{
|
||||
let text = horizon_client::text::Text::translate(description, &[]);
|
||||
let mut ty = at[1];
|
||||
for line in atlas.font.split_lines(&text, layout.width) {
|
||||
batch.text_styled(atlas, &line.0, at[0], ty, 0xFFAA_AAAA, false);
|
||||
ty += 9.0;
|
||||
}
|
||||
}
|
||||
let [bx, by] = layout.bar;
|
||||
let fill_sprite = if layout.full {
|
||||
"gui/sprites/container/bundle/bundle_progressbar_full"
|
||||
} else {
|
||||
"gui/sprites/container/bundle/bundle_progressbar_fill"
|
||||
};
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
fill_sprite,
|
||||
bx + 1.0,
|
||||
by,
|
||||
layout.fill,
|
||||
13.0,
|
||||
u32::MAX,
|
||||
);
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/sprites/container/bundle/bundle_progressbar_border",
|
||||
bx,
|
||||
by,
|
||||
96.0,
|
||||
13.0,
|
||||
u32::MAX,
|
||||
);
|
||||
if let Some(key) = layout.label {
|
||||
let label = horizon_client::text::Text::translate(key, &[]);
|
||||
let w = atlas.font.width_text(&label);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&label,
|
||||
bx + 48.0 - (w / 2.0).floor(),
|
||||
by + 3.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Hovered-slot tooltip on the plain inventory, crafting and container screens.
|
||||
pub(super) fn draw_slot_tooltip(&mut self, batch: &mut HudBatch) {
|
||||
if self.carried.is_some() {
|
||||
@@ -1984,8 +2193,7 @@ impl Game<'_> {
|
||||
};
|
||||
let stack = self.slot_view(slot);
|
||||
if let Some(stack) = stack {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(text) = self.crafter_tooltip(slot) {
|
||||
self.draw_tooltip_lines(
|
||||
batch,
|
||||
@@ -2041,5 +2249,13 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The anvil's rename box (`AnvilScreen`: `setMaxLength(50)`).
|
||||
const ANVIL_NAME: horizon_client::clipboard::FieldId = ("anvil.name", 0);
|
||||
const ANVIL_NAME_CHARS: usize = 50;
|
||||
|
||||
fn anvil_box() -> horizon_client::ui::EditBox {
|
||||
let mut edit = horizon_client::ui::EditBox::new(0.0, 0.0, 103.0, 12.0);
|
||||
edit.bordered = false;
|
||||
edit.max_length = ANVIL_NAME_CHARS;
|
||||
edit.text_color = u32::MAX;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
@@ -803,8 +803,7 @@ impl Game<'_> {
|
||||
text.split('\n').map(|l| (l.to_owned(), u32::MAX)).collect();
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(stack) = tooltip {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ mod arena;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
mod hdr;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod native_material;
|
||||
@@ -504,6 +505,10 @@ pub struct Renderer {
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is scRGB.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// Whether the window's surface can present scRGB.
|
||||
hdr_capable: bool,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
@@ -910,15 +915,24 @@ async fn open_device(
|
||||
limits.max_storage_buffer_binding_size,
|
||||
limits.max_texture_array_layers,
|
||||
));
|
||||
match adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
required_features: features,
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
let desc = wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
required_features: features,
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let opened = match info.backend {
|
||||
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
|
||||
_ => None,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let opened = None;
|
||||
let opened = match opened {
|
||||
Some(opened) => opened,
|
||||
None => adapter.request_device(&desc).await,
|
||||
};
|
||||
match opened {
|
||||
Ok((device, queue)) => {
|
||||
return Ok(Opened {
|
||||
instance,
|
||||
@@ -1043,7 +1057,7 @@ impl Renderer {
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let (config, present_modes) = if let Some(surface) = &surface {
|
||||
let (config, present_modes, hdr_capable) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
|
||||
.ok_or("surface has no supported configuration")?;
|
||||
@@ -1071,7 +1085,7 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone())
|
||||
(config, caps.present_modes.clone(), hdr::supported(&caps))
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
@@ -1085,7 +1099,7 @@ impl Renderer {
|
||||
view_formats: vec![format],
|
||||
color_space: wgpu::SurfaceColorSpace::default(),
|
||||
};
|
||||
(config, Vec::new())
|
||||
(config, Vec::new(), false)
|
||||
};
|
||||
let offscreen = surface
|
||||
.is_none()
|
||||
@@ -1929,14 +1943,20 @@ impl Renderer {
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
// RenderPipelines.CELESTIAL: additive (src alpha, one).
|
||||
// RenderPipelines.CELESTIAL: additive (src alpha, one). The
|
||||
// alpha goes to 0: HDR output (hdr.wgsl) lets the sun and
|
||||
// moon shine; nothing else reads the frame's alpha.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent::OVER,
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::Zero,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -2420,6 +2440,8 @@ impl Renderer {
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_capable,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -2489,7 +2511,12 @@ impl Renderer {
|
||||
// Wayland allows only one tearing-control object per window surface.
|
||||
self.release_surface();
|
||||
let surface = self.instance.create_surface(self.window.clone())?;
|
||||
surface.configure(&self.device, &self.config);
|
||||
Self::configure(
|
||||
&self.device,
|
||||
&surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
self.surface = Some(surface);
|
||||
Ok(())
|
||||
}
|
||||
@@ -2552,7 +2579,12 @@ impl Renderer {
|
||||
&& let Some(surface) = &self.surface
|
||||
{
|
||||
self.config.present_mode = mode;
|
||||
surface.configure(&self.device, &self.config);
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
@@ -2563,7 +2595,12 @@ impl Renderer {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
if let Some(surface) = &self.surface {
|
||||
surface.configure(&self.device, &self.config);
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
} else {
|
||||
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
|
||||
}
|
||||
@@ -2895,6 +2932,63 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
|
||||
/// Configures `surface` and, while HDR output is on, sends its HDR metadata
|
||||
/// again: a new swapchain starts without it.
|
||||
fn configure(
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Surface<'_>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
hdr: Option<&hdr::Hdr>,
|
||||
) {
|
||||
surface.configure(device, config);
|
||||
if let Some(hdr) = hdr {
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak);
|
||||
}
|
||||
}
|
||||
fn configure_surface(&self) {
|
||||
if let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
fn send_hdr_metadata(&self) {
|
||||
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
/// scRGB while HDR output is on.
|
||||
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
|
||||
let mut config = self.config.clone();
|
||||
if self.hdr.is_some() {
|
||||
config.format = hdr::FORMAT;
|
||||
config.view_formats = vec![hdr::FORMAT];
|
||||
config.color_space = hdr::COLOR_SPACE;
|
||||
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
}
|
||||
config
|
||||
}
|
||||
/// HDR output (Horizon setting): 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) {
|
||||
let on = on && self.hdr_capable && self.surface.is_some();
|
||||
if on != self.hdr.is_some() {
|
||||
self.hdr =
|
||||
on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix()));
|
||||
self.configure_surface();
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
|
||||
{
|
||||
hdr.paper_white = paper_white;
|
||||
hdr.peak = peak;
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool) {
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias;
|
||||
@@ -3230,7 +3324,12 @@ impl Renderer {
|
||||
}
|
||||
Some(wgpu::CurrentSurfaceTexture::Outdated) => {
|
||||
if let Some(surface) = &self.surface {
|
||||
surface.configure(&self.device, &self.config);
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -3242,9 +3341,11 @@ impl Renderer {
|
||||
return Err("surface acquisition failed validation".into());
|
||||
}
|
||||
};
|
||||
let target_texture = match &surface_frame {
|
||||
Some(f) => f.texture.clone(),
|
||||
None => self.offscreen.clone().ok_or("no offscreen target")?,
|
||||
let size = [self.config.width, self.config.height];
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
(Some(f), None) => f.texture.clone(),
|
||||
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
|
||||
};
|
||||
// One command buffer for the frame, after the section copies staged
|
||||
// since the last one: Metal commits every command buffer wgpu-core
|
||||
@@ -4642,6 +4743,9 @@ impl Renderer {
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
}
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(&mut encoder);
|
||||
}
|
||||
if hand_quads > 0 && !pack_draws_hand {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
@@ -4796,6 +4900,9 @@ impl Renderer {
|
||||
self.section_copy_bytes = 0;
|
||||
self.section_uploads.finish(&encoder);
|
||||
}
|
||||
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
|
||||
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
|
||||
}
|
||||
let commands = crate::spans::time("encode", || encoder.finish());
|
||||
let first_frame = self.first_frame == FirstFrame::Submit;
|
||||
if first_frame {
|
||||
@@ -4832,7 +4939,12 @@ impl Renderer {
|
||||
}
|
||||
self.queue.present(surface_frame);
|
||||
if reconfigure && let Some(surface) = &self.surface {
|
||||
surface.configure(&self.device, &self.config);
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! HDR output (Horizon setting, off by default): the frame renders exactly as
|
||||
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
|
||||
//! (`hdr.wgsl`) writes it to an scRGB swapchain at the player's paper white,
|
||||
//! expanding the world's highlights towards their peak brightness. The world
|
||||
//! is copied aside before the hand and HUD so only it is expanded.
|
||||
|
||||
/// The swapchain format and colour space HDR output presents in.
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
pub const COLOR_SPACE: wgpu::SurfaceColorSpace = wgpu::SurfaceColorSpace::ExtendedSrgbLinear;
|
||||
|
||||
/// Whether `caps` can present scRGB.
|
||||
pub fn supported(caps: &wgpu::SurfaceCapabilities) -> bool {
|
||||
caps.color_spaces(FORMAT)
|
||||
.contains(wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR)
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
frame: wgpu::Texture,
|
||||
world: wgpu::Texture,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta.
|
||||
debug: bool,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
|
||||
});
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
entries: &[
|
||||
texture(0),
|
||||
texture(1),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
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 HDR"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
params,
|
||||
format,
|
||||
targets: None,
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 8-bit frame every pass draws into this frame, sized like the window.
|
||||
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
|
||||
if self.targets.as_ref().is_none_or(|t| t.size != size) {
|
||||
let texture = |label, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0].max(1),
|
||||
height: size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: self.format,
|
||||
usage: usage | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let frame = texture(
|
||||
"Horizon HDR frame",
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST);
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&frame)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&world)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
frame,
|
||||
world,
|
||||
group,
|
||||
});
|
||||
}
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it.
|
||||
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.frame.as_image_copy(),
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Writes the finished frame to the swapchain image `target`.
|
||||
pub fn present(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::Texture,
|
||||
world: bool,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let params = [
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
];
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
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 HDR"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &t.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
out.tint = color;
|
||||
out.tint = vec4(color.rgb, 1.0);
|
||||
let block = max(f32(light.y), horizon_terrain_dynamic_block_light(pos));
|
||||
let sky = f32(light.x);
|
||||
// 16 per level, 15 levels.
|
||||
|
||||
@@ -169,6 +169,7 @@ impl PackAtlas {
|
||||
];
|
||||
let columns = self.columns;
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
@@ -178,7 +179,7 @@ impl PackAtlas {
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = hz_v_color;\n \
|
||||
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
|
||||
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
|
||||
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
|
||||
scale[0], scale[1],
|
||||
|
||||
@@ -399,7 +399,8 @@ impl FrameUniforms {
|
||||
_ => Value::i(&[0]),
|
||||
}
|
||||
};
|
||||
self.values = NAMES.iter().map(|n| value(n)).collect();
|
||||
self.values.clear();
|
||||
self.values.extend(NAMES.iter().map(|n| value(n)));
|
||||
self.frame = (self.frame + 1) % 720_720;
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,12 @@ pub struct Graphics {
|
||||
pub resolution_scale: u32,
|
||||
/// FXAA over the world.
|
||||
pub fxaa: bool,
|
||||
/// HDR output (scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
pub hdr_paper_white: u32,
|
||||
/// HDR peak brightness the world's highlights reach, in nits.
|
||||
pub hdr_peak: u32,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
@@ -286,6 +292,9 @@ impl Default for Graphics {
|
||||
overlay: Overlay::Off,
|
||||
resolution_scale: 100,
|
||||
fxaa: false,
|
||||
hdr: false,
|
||||
hdr_paper_white: 200,
|
||||
hdr_peak: 1000,
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
@@ -337,6 +346,9 @@ 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),
|
||||
"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),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
@@ -354,7 +366,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:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -379,6 +391,9 @@ impl Graphics {
|
||||
},
|
||||
self.resolution_scale,
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
@@ -444,7 +459,7 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
|
||||
let g = &mut s.horizon;
|
||||
g.preset = preset;
|
||||
g.cave_culling = true;
|
||||
g.animated_textures = preset != Preset::Performance;
|
||||
g.animated_textures = true;
|
||||
g.chunk_updates = 0;
|
||||
g.upload_budget_kib = 0;
|
||||
g.sky = true;
|
||||
@@ -476,6 +491,9 @@ mod tests {
|
||||
overlay: Overlay::Graph,
|
||||
resolution_scale: 75,
|
||||
fxaa: true,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
|
||||
@@ -37,6 +37,9 @@ pub enum H {
|
||||
Overlay,
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Hdr,
|
||||
HdrPaperWhite,
|
||||
HdrPeak,
|
||||
BatteryMode,
|
||||
GraphicsApi,
|
||||
}
|
||||
@@ -221,6 +224,21 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Medium,
|
||||
"FXAA smooths jagged edges of the world; slightly softer image.",
|
||||
),
|
||||
h(
|
||||
H::Hdr,
|
||||
None,
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"HDR: how bright white paper and menus look. Match your desktop.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"HDR: how bright the brightest highlights get. Your display's peak.",
|
||||
),
|
||||
h(
|
||||
H::PresentMode,
|
||||
None,
|
||||
@@ -324,6 +342,8 @@ impl H {
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0),
|
||||
H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -365,6 +385,9 @@ impl H {
|
||||
},
|
||||
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
|
||||
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
|
||||
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)),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
@@ -387,6 +410,8 @@ impl H {
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32,
|
||||
H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -418,6 +443,7 @@ impl H {
|
||||
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,
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::GraphicsApi => g.api = step(&GraphicsApi::ALL, g.api, back),
|
||||
H::Pacing => {
|
||||
@@ -453,6 +479,7 @@ impl Item {
|
||||
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,
|
||||
Item::Vanilla(Opt::Vsync) => s.vsync,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Horizon's HDR output pass: the frame, drawn as without HDR in vanilla's gamma
|
||||
// space, decoded to linear light at the paper white and written to the scRGB
|
||||
// swapchain (1.0 = 80 nits). Bright pixels of light sources that still show the
|
||||
// world as it was before the hand and HUD are expanded towards the peak.
|
||||
@group(0) @binding(0) var frame: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world: texture_2d<f32>;
|
||||
|
||||
struct Params {
|
||||
// Paper white in scRGB units (nits / 80).
|
||||
paper: f32,
|
||||
// Peak brightness relative to paper white; 1 expands nothing.
|
||||
peak: f32,
|
||||
// 1 when this frame drew a world.
|
||||
world: f32,
|
||||
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
|
||||
debug: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
|
||||
// face) nearly as it was. Pixels brighter than LIFT_FULL get STRENGTH of the
|
||||
// headroom, dimmer ones less down to none at LIFT_START (lava's crust, a
|
||||
// lantern's metal), and no channel goes past the peak. Brightness is the mean
|
||||
// of luminance and the brightest channel, so saturated light counts as bright.
|
||||
const LIFT_START: f32 = 0.18;
|
||||
const LIFT_FULL: f32 = 0.42;
|
||||
const STRENGTH: f32 = 0.7;
|
||||
|
||||
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
|
||||
// across its channels, as the vignette and other screen-wide tints darken it.
|
||||
// The hand, HUD and text replace pixels instead, so they never pass.
|
||||
fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
if all(c == w) {
|
||||
return true;
|
||||
}
|
||||
let k = c / max(w, vec3(1.0 / 255.0));
|
||||
let mean = (k.r + k.g + k.b) / 3.0;
|
||||
return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03));
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let c = textureLoad(frame, at, 0).rgb;
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
|
||||
let y = 0.5 * (dot(light, vec3(0.2126, 0.7152, 0.0722)) + max(light.r, max(light.g, light.b)));
|
||||
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * STRENGTH * lift, params.peak / max(top, 1e-4));
|
||||
light *= max(gain, 1.0);
|
||||
if params.debug > 0.5 {
|
||||
light = mix(light, vec3(gain, 0.0, gain), 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
return vec4(light * params.paper, 1.0);
|
||||
}
|
||||
@@ -1111,8 +1111,9 @@ impl SubtitleOverlay {
|
||||
s.purge_old_instances(3000.0 * frame.multiplier, now);
|
||||
}
|
||||
if s.audible_from(position) && !s.played.is_empty() {
|
||||
width = width.max(width_of(&Text::translate(&s.key, &[])));
|
||||
audible.push(i);
|
||||
let text_width = width_of(&Text::translate(&s.key, &[]));
|
||||
width = width.max(text_width);
|
||||
audible.push((i, text_width));
|
||||
}
|
||||
}
|
||||
if audible.is_empty() {
|
||||
@@ -1121,7 +1122,7 @@ impl SubtitleOverlay {
|
||||
let w = |s: &str| font.width(s) as i32;
|
||||
width += w("<") + w(" ") + w(">") + w(" ");
|
||||
let mut row = 0;
|
||||
for i in audible {
|
||||
for (i, text_width) in audible {
|
||||
let s = &self.subtitles[i];
|
||||
let text_value = Text::translate(&s.key, &[]);
|
||||
let Some(closest) = s.best_candidate(position) else {
|
||||
@@ -1145,7 +1146,6 @@ impl SubtitleOverlay {
|
||||
let half_width = width / 2;
|
||||
let height = 9;
|
||||
let half_height = height / 2;
|
||||
let text_width = width_of(&text_value);
|
||||
let brightness = s.brightness(closest, frame.multiplier, now);
|
||||
out.push(GuiDraw::Push(vec![
|
||||
PoseOp::Translate(
|
||||
|
||||
@@ -204,6 +204,14 @@ impl ItemStack {
|
||||
pub fn custom_name(&self) -> Option<&str> {
|
||||
self.data().and_then(|d| d.custom_name.as_deref())
|
||||
}
|
||||
/// `minecraft:item_name` in the current language (`getItemName`'s
|
||||
/// component). It stays in the patch as loaded, so a translatable name
|
||||
/// saves and reaches clients unchanged; the ominous banner's is modelled
|
||||
/// by `ominous_banner` instead.
|
||||
pub fn item_name(&self) -> Option<String> {
|
||||
let name = self.data()?.other("minecraft:item_name")?;
|
||||
Some(crate::entity_nbt::name_text(name).plain())
|
||||
}
|
||||
pub fn potion(&self) -> Option<&str> {
|
||||
self.data().and_then(|d| d.potion.as_deref())
|
||||
}
|
||||
|
||||
@@ -238,6 +238,125 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::registry::Ids;
|
||||
|
||||
#[test]
|
||||
fn merchant_water_cost_survives_the_offer_packet() {
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::play::{NetOffer, Offers},
|
||||
};
|
||||
let mut components = vec![String::new(); component::POTION_CONTENTS as usize + 1];
|
||||
components[component::POTION_CONTENTS as usize] = "minecraft:potion_contents".into();
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".into(),
|
||||
Ids::from_names(vec!["minecraft:potion".into(), "minecraft:emerald".into()]),
|
||||
),
|
||||
(
|
||||
"minecraft:potion".into(),
|
||||
Ids::from_names(vec!["minecraft:water".into()]),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".into(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let dynamic = Dynamic::default();
|
||||
let water = ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water");
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|
||||
|registry, name| builtin.get(registry)?.id(name),
|
||||
&Some(water.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let offers = vec![NetOffer {
|
||||
cost_a: wire.into(),
|
||||
result: Stack::new(1, 1),
|
||||
cost_b: Some(Stack::new(1, 2).into()),
|
||||
out_of_stock: false,
|
||||
uses: 0,
|
||||
max_uses: 2,
|
||||
xp: 0,
|
||||
special_price: 0,
|
||||
price_multiplier: 0.05,
|
||||
demand: 0,
|
||||
}];
|
||||
let mut w = Writer::new();
|
||||
Offers::write(&offers, &mut w);
|
||||
let mut r = Reader::new(&w.data);
|
||||
let decoded = Offers::read(&mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0);
|
||||
let cost = crate::merchant::ItemCost {
|
||||
stack: from_network(&decoded[0].cost_a.clone().into(), &builtin, &dynamic),
|
||||
};
|
||||
assert_eq!(cost.stack, water);
|
||||
assert_eq!(cost.stack.potion(), Some("minecraft:water"));
|
||||
let authoritative = crate::merchant::ItemCost {
|
||||
stack: water.clone(),
|
||||
};
|
||||
for payment in [
|
||||
water,
|
||||
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness"),
|
||||
ItemStack::new("minecraft:potion", 1),
|
||||
] {
|
||||
assert_eq!(cost.test(&payment), authoritative.test(&payment));
|
||||
}
|
||||
assert!(cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water")));
|
||||
assert!(
|
||||
!cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness"))
|
||||
);
|
||||
let emerald = crate::merchant::ItemCost {
|
||||
stack: from_network(
|
||||
&decoded[0].cost_b.clone().unwrap().into(),
|
||||
&builtin,
|
||||
&dynamic,
|
||||
),
|
||||
};
|
||||
assert!(emerald.test(&ItemStack::new("minecraft:emerald", 2)));
|
||||
}
|
||||
|
||||
/// A bundle's contents cross the wire, so a server's client can draw
|
||||
/// its tooltip (BundleContents' stream codec: a list of templates).
|
||||
#[test]
|
||||
fn a_bundle_keeps_its_contents_across_the_wire() {
|
||||
let mut components = vec![String::new(); 60];
|
||||
components[component::BUNDLE_CONTENTS as usize] = "minecraft:bundle_contents".into();
|
||||
let items = ["minecraft:air", "minecraft:bundle", "minecraft:stone"];
|
||||
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(),
|
||||
};
|
||||
let mut bundle = ItemStack::new("minecraft:bundle", 1);
|
||||
bundle.data_mut().bundle_contents = vec![ItemStack::new("minecraft:stone", 32)];
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| items.iter().position(|i| *i == name).unwrap_or(0) as i32,
|
||||
|_, _| None,
|
||||
&Some(bundle),
|
||||
)
|
||||
.unwrap();
|
||||
let back = from_network(&wire, &builtin, &dynamic);
|
||||
let contents = &back.data().expect("components").bundle_contents;
|
||||
assert_eq!(contents.len(), 1);
|
||||
assert_eq!(
|
||||
(&*contents[0].item, contents[0].count),
|
||||
("minecraft:stone", 32)
|
||||
);
|
||||
}
|
||||
|
||||
/// What a creative client sends (`stack_to_network`) comes back whole on
|
||||
/// the server: damage, both enchantment lists, repair cost, name, trim.
|
||||
#[test]
|
||||
@@ -246,6 +365,7 @@ mod tests {
|
||||
for (id, name) in [
|
||||
(component::DAMAGE, "minecraft:damage"),
|
||||
(component::CUSTOM_NAME, "minecraft:custom_name"),
|
||||
(component::ITEM_NAME, "minecraft:item_name"),
|
||||
(component::ENCHANTMENTS, "minecraft:enchantments"),
|
||||
(component::POTION_CONTENTS, "minecraft:potion_contents"),
|
||||
(component::REPAIR_COST, "minecraft:repair_cost"),
|
||||
@@ -305,6 +425,10 @@ mod tests {
|
||||
.push((Arc::from("minecraft:sharpness"), 2));
|
||||
d.repair_cost = 9;
|
||||
d.custom_name = Some("Keeper".to_owned());
|
||||
// A translatable item name reaches the client as the same component.
|
||||
let item_name = Tag::compound([("translate", Tag::String("filled_map.monument".into()))]);
|
||||
d.other
|
||||
.push(("minecraft:item_name".into(), item_name.clone()));
|
||||
d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")));
|
||||
let id = |registry: &str, name: &str| {
|
||||
builtin
|
||||
@@ -334,6 +458,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(d.repair_cost, 9);
|
||||
assert_eq!(d.custom_name.as_deref(), Some("Keeper"));
|
||||
assert_eq!(d.other("minecraft:item_name"), Some(&item_name));
|
||||
assert_eq!(
|
||||
d.trim,
|
||||
Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")))
|
||||
|
||||
@@ -188,6 +188,9 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
|
||||
if let Some(name) = stack.custom_name() {
|
||||
return name.to_owned();
|
||||
}
|
||||
if let Some(name) = stack.item_name() {
|
||||
return name;
|
||||
}
|
||||
if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
return translate("block.minecraft.ominous_banner")
|
||||
.unwrap_or_else(|| "Ominous Banner".to_owned());
|
||||
@@ -611,6 +614,14 @@ mod tests {
|
||||
assert_eq!(rarity(&banner, "common"), "uncommon");
|
||||
banner.data_mut().custom_name = Some("Captain's Standard".into());
|
||||
assert_eq!(name_line(&banner, &translate), "Captain's Standard");
|
||||
let mut custom_item = ItemStack::new("minecraft:diamond_sword", 1);
|
||||
custom_item.data_mut().other.push((
|
||||
"minecraft:item_name".into(),
|
||||
Tag::String("Excalibur".into()),
|
||||
));
|
||||
assert_eq!(name_line(&custom_item, &translate), "Excalibur");
|
||||
custom_item.data_mut().custom_name = Some("Custom Blade".into());
|
||||
assert_eq!(name_line(&custom_item, &translate), "Custom Blade");
|
||||
banner.enchant("minecraft:unbreaking", 1);
|
||||
assert_eq!(rarity(&banner, "common"), "rare");
|
||||
let plain = ItemStack::new("minecraft:white_banner", 1);
|
||||
|
||||
@@ -120,6 +120,8 @@ pub struct ItemInfo {
|
||||
pub crafting_remainder: Option<String>,
|
||||
/// `dye`: the dye colour name (dyes, and anything else with the component).
|
||||
pub dye: Option<String>,
|
||||
/// `cushion/color`: the DyeColor name of a cushion item.
|
||||
pub cushion_color: Option<String>,
|
||||
/// `block_transformer`: the registry entry an axe, hoe or shovel uses on blocks.
|
||||
pub block_transformer: Option<String>,
|
||||
/// Has a default `bundle_contents` (the bundles, `BundleItem`).
|
||||
@@ -251,6 +253,10 @@ pub struct Items {
|
||||
loot: HashMap<String, Value>,
|
||||
/// The `jukebox_song` registry.
|
||||
pub songs: HashMap<String, Song>,
|
||||
/// The enchantment registry: loot functions enchant the items they roll
|
||||
/// (`EnchantWithLevelsFunction`, `EnchantRandomlyFunction`), and the
|
||||
/// station menus share this copy.
|
||||
pub enchantments: Arc<crate::enchant::Enchantments>,
|
||||
}
|
||||
/// A `JukeboxSong`.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -599,6 +605,7 @@ impl Items {
|
||||
.map(str::to_owned),
|
||||
jukebox_song: c["minecraft:jukebox_playable"].as_str().map(str::to_owned),
|
||||
dye: c["minecraft:dye"].as_str().map(str::to_owned),
|
||||
cushion_color: c["minecraft:cushion/color"].as_str().map(str::to_owned),
|
||||
block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned),
|
||||
bundle: c.get("minecraft:bundle_contents").is_some(),
|
||||
crafting_remainder: CRAFT_REMAINDERS
|
||||
@@ -701,12 +708,15 @@ impl Items {
|
||||
item_tag_order: load_tag_lists(root, "item")?,
|
||||
loot,
|
||||
songs: load_songs(root),
|
||||
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
|
||||
})
|
||||
}
|
||||
pub fn get(&self, id: &str) -> Option<&ItemInfo> {
|
||||
self.items.get(id)
|
||||
}
|
||||
/// Only the block and item tags, for tools that need tag membership.
|
||||
/// Only the block and item tags (and the enchantment registry, which the
|
||||
/// station rules and loot functions are built from), for tools that need
|
||||
/// tag membership.
|
||||
pub fn tags_only(root: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
items: HashMap::new(),
|
||||
@@ -717,6 +727,7 @@ impl Items {
|
||||
item_tag_order: load_tag_lists(root, "item")?,
|
||||
loot: HashMap::new(),
|
||||
songs: HashMap::new(),
|
||||
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
|
||||
})
|
||||
}
|
||||
/// An item table with only tags, for unit tests of item rules.
|
||||
@@ -734,6 +745,7 @@ impl Items {
|
||||
item_tags,
|
||||
loot: HashMap::new(),
|
||||
songs: HashMap::new(),
|
||||
enchantments: Arc::new(crate::enchant::Enchantments::default()),
|
||||
}
|
||||
}
|
||||
/// Whether `block` is in the block tag `tag` (`minecraft:fire`, no `#`).
|
||||
@@ -1371,6 +1383,16 @@ impl LootContext<'_> {
|
||||
self.modifiers(&modifiers, &mut stack, random);
|
||||
produced.push(stack);
|
||||
}
|
||||
/// An enchantment function's `options` (`HolderSet`): a tag, a list or
|
||||
/// one id; every enchantment when absent (`registryAccess().listElements`).
|
||||
fn enchant_pool(e: &crate::enchant::Enchantments, options: &Value) -> Vec<Arc<str>> {
|
||||
match options {
|
||||
Value::String(s) if s.starts_with('#') => e.tag(s).to_vec(),
|
||||
Value::String(s) => vec![Arc::from(s.as_str())],
|
||||
Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(Arc::from)).collect(),
|
||||
_ => e.list.iter().map(|x| Arc::clone(&x.id)).collect(),
|
||||
}
|
||||
}
|
||||
fn modifiers<R: RandomSource>(&self, m: &Value, stack: &mut ItemStack, random: &mut R) {
|
||||
let list: Vec<&Value> = match m {
|
||||
Value::Array(a) => a.iter().collect(),
|
||||
@@ -1449,6 +1471,54 @@ impl LootContext<'_> {
|
||||
.with_potion(id);
|
||||
}
|
||||
}
|
||||
// EnchantWithLevelsFunction: EnchantmentHelper.enchantItem on
|
||||
// the context's random (the End cities' gear).
|
||||
"minecraft:enchant_with_levels" => {
|
||||
let cost = Self::int_number(&f["levels"], random);
|
||||
let e = &self.items.enchantments;
|
||||
let pool = Self::enchant_pool(e, &f["options"]);
|
||||
let chosen = e.select(self.items, random, &stack.item, cost, &pool);
|
||||
// EnchantmentHelper.enchantItem: a book becomes one
|
||||
// enchanted book (the fresh stack's count).
|
||||
if &*stack.item == "minecraft:book" {
|
||||
*stack = ItemStack::new("minecraft:enchanted_book", 1);
|
||||
}
|
||||
for (id, level) in chosen {
|
||||
stack.enchant(&id, level);
|
||||
}
|
||||
}
|
||||
// EnchantRandomlyFunction: one enchantment the item accepts,
|
||||
// at a level between its min and max.
|
||||
"minecraft:enchant_randomly" => {
|
||||
let e = &self.items.enchantments;
|
||||
let book = &*stack.item == "minecraft:book";
|
||||
let only_compatible = f["only_compatible"].as_bool().unwrap_or(true);
|
||||
let options: Vec<Arc<str>> = Self::enchant_pool(e, &f["options"])
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
book || !only_compatible || e.can_enchant(self.items, id, &stack.item)
|
||||
})
|
||||
.collect();
|
||||
if options.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let id = options
|
||||
[random.next_int_bound(options.len() as i32).unwrap_or(0) as usize]
|
||||
.clone();
|
||||
let max = e.get(&id).map_or(1, |d| d.max_level).max(1);
|
||||
// `Mth.nextInt(random, 1, max)`: a single-level
|
||||
// enchantment (Mending) returns 1 without drawing, so the
|
||||
// rolls after this one keep vanilla's sequence.
|
||||
let level = if max <= 1 {
|
||||
1
|
||||
} else {
|
||||
1 + random.next_int_bound(max as i32).unwrap_or(0) as u32
|
||||
};
|
||||
if book {
|
||||
*stack = ItemStack::new("minecraft:enchanted_book", 1);
|
||||
}
|
||||
stack.enchant(&id, level);
|
||||
}
|
||||
"minecraft:limit_count" => {
|
||||
let limit = &f["limit"];
|
||||
if let Some(min) = limit["min"].as_i64() {
|
||||
@@ -1658,6 +1728,102 @@ mod tests {
|
||||
assert_eq!(drops[0].potion(), Some("minecraft:fire_resistance"));
|
||||
}
|
||||
|
||||
/// `/loot give` and every other table this dispatcher rolls: the two
|
||||
/// enchantment functions (`EnchantWithLevelsFunction`,
|
||||
/// `EnchantRandomlyFunction`) put their enchantments on the stack.
|
||||
#[test]
|
||||
fn loot_enchant_functions_enchant_the_rolled_item() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let props = BTreeMap::new();
|
||||
let mut random = LegacyRandom::new(11);
|
||||
// A sword at levels 20..39 comes out enchanted, as in an End city.
|
||||
let levels = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
|
||||
"name":"minecraft:diamond_sword","modifier":[
|
||||
{"type":"minecraft:enchant_with_levels","levels":{"type":"minecraft:uniform","min":20.0,"max":39.0}}]}]}]});
|
||||
let mut enchanted = 0;
|
||||
for _ in 0..40 {
|
||||
let drops = ctx(&items, None, &props, "").evaluate(&levels, &mut random);
|
||||
if drops[0].enchantments().is_empty() {
|
||||
continue;
|
||||
}
|
||||
enchanted += 1;
|
||||
assert_eq!(&*drops[0].item, "minecraft:diamond_sword");
|
||||
// Levels stay within the enchantment's range.
|
||||
for (id, level) in drops[0].enchantments() {
|
||||
let max = items
|
||||
.enchantments
|
||||
.get(id)
|
||||
.expect("a known enchantment")
|
||||
.max_level;
|
||||
assert!((1..=max).contains(level), "{id} at {level}");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
enchanted > 30,
|
||||
"only {enchanted} of 40 rolls were enchanted"
|
||||
);
|
||||
// `enchant_randomly` on a book makes one enchanted book, its levels
|
||||
// stored; the pool is the enchantment tag it names.
|
||||
let book = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
|
||||
"name":"minecraft:book","modifier":[
|
||||
{"type":"minecraft:enchant_randomly","options":"#minecraft:on_random_loot"}]}]}]});
|
||||
let drops = ctx(&items, None, &props, "").evaluate(&book, &mut random);
|
||||
assert_eq!(&*drops[0].item, "minecraft:enchanted_book");
|
||||
assert_eq!(drops[0].count, 1);
|
||||
assert!(drops[0].enchantments().is_empty());
|
||||
let stored = drops[0].stored_enchantments();
|
||||
assert_eq!(stored.len(), 1, "{stored:?}");
|
||||
assert!(
|
||||
items
|
||||
.enchantments
|
||||
.in_tag("minecraft:on_random_loot", &stored[0].0)
|
||||
);
|
||||
// An item no enchantment accepts keeps none of them.
|
||||
let dirt = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
|
||||
"name":"minecraft:dirt","modifier":[
|
||||
{"type":"minecraft:enchant_with_levels","levels":{"type":"minecraft:constant","value":30.0}}]}]}]});
|
||||
let drops = ctx(&items, None, &props, "").evaluate(&dirt, &mut random);
|
||||
assert!(drops[0].enchantments().is_empty());
|
||||
}
|
||||
|
||||
/// `Mth.nextInt(random, 1, max)` returns `1` without drawing when the
|
||||
/// enchantment has one level (`EnchantRandomlyFunction.enchantItem`), so
|
||||
/// the rolls after it must not shift. Vanilla with `LegacyRandom(11)`: a
|
||||
/// Mending book, then `set_count` uniform 1..64, is 46.
|
||||
#[test]
|
||||
fn a_single_level_enchantment_draws_no_level() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = Items::load(&root, assets.registry, &facts).unwrap();
|
||||
let props = BTreeMap::new();
|
||||
let table = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
|
||||
"name":"minecraft:book","modifier":[
|
||||
{"type":"minecraft:enchant_randomly","options":"minecraft:mending"},
|
||||
{"type":"minecraft:set_count","count":{"type":"minecraft:uniform","min":1.0,"max":64.0}}]}]}]});
|
||||
let drops = ctx(&items, None, &props, "").evaluate(&table, &mut LegacyRandom::new(11));
|
||||
assert_eq!(drops.len(), 1);
|
||||
assert_eq!(&*drops[0].item, "minecraft:enchanted_book");
|
||||
assert_eq!(
|
||||
drops[0].stored_enchantments(),
|
||||
[("minecraft:mending".to_owned().into(), 1)],
|
||||
"the book keeps its single-level enchantment"
|
||||
);
|
||||
// 46 is vanilla's count for this seed; an extra level draw gives 28.
|
||||
assert_eq!(drops[0].count, 46);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn always_edible_and_consume_effects_loaded() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -952,6 +952,11 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
BlockEntity::Jukebox(_) | BlockEntity::Lectern(_) => None,
|
||||
// CampfireBlockEntity.getUpdateTag: the items alone.
|
||||
BlockEntity::Campfire(c) => Some(Tag::compound([(
|
||||
"Items",
|
||||
crate::save::encode_items(&c.items),
|
||||
)])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,6 +823,17 @@ fn login_and_configure(
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
// A Horizon client hears that this server runs its mods' quiet commands
|
||||
// (horizon_protocol::quiet); a vanilla one is told nothing.
|
||||
if brand == "horizon" {
|
||||
send(
|
||||
c,
|
||||
cfg::Clientbound::CustomPayload(cfg::CustomPayload {
|
||||
channel: horizon_protocol::quiet::CHANNEL.into(),
|
||||
data: Vec::new(),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
for packet in sync.registry_packets(known_pack) {
|
||||
send(c, cfg::Clientbound::RegistryData(packet))?;
|
||||
}
|
||||
|
||||
@@ -237,6 +237,8 @@ pub struct Guest {
|
||||
pub drag: Option<(u8, Vec<usize>)>,
|
||||
/// The compound this player was loaded from, for fields Horizon does not model.
|
||||
pub saved: Option<horizon_world::nbt::Tag>,
|
||||
/// `ServerPlayer.seenCredits`: the end poem has been shown.
|
||||
pub seen_credits: bool,
|
||||
/// `ServerPlayer.recipeBook`.
|
||||
pub recipe_book: crate::recipe_book::Settings,
|
||||
/// `PlayerAdvancements`: the criteria obtained, whether they came from
|
||||
@@ -358,6 +360,7 @@ impl Guest {
|
||||
next_window: 0,
|
||||
drag: None,
|
||||
saved: None,
|
||||
seen_credits: false,
|
||||
recipe_book: Default::default(),
|
||||
advancements: Default::default(),
|
||||
advancements_loaded: false,
|
||||
@@ -601,6 +604,18 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Stacks(charged),
|
||||
));
|
||||
}
|
||||
// What a bundle holds, for its tooltip (ClientBundleTooltip).
|
||||
if !d.bundle_contents.is_empty() {
|
||||
let contents = d
|
||||
.bundle_contents
|
||||
.iter()
|
||||
.filter_map(|c| network_stack(item_id, synced_id, &Some(c.clone())))
|
||||
.collect();
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::BUNDLE_CONTENTS,
|
||||
horizon_protocol::item::Component::Stacks(contents),
|
||||
));
|
||||
}
|
||||
// The books a lectern shows and a guest reads.
|
||||
if !d.writable_pages.is_empty() {
|
||||
stack.added.push((
|
||||
@@ -633,6 +648,13 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Text(horizon_protocol::text::literal(name)),
|
||||
));
|
||||
}
|
||||
// The component as loaded, so a translatable name stays one.
|
||||
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::ITEM_NAME,
|
||||
horizon_protocol::item::Component::Text(name.clone()),
|
||||
));
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,11 @@ pub enum HostEvent {
|
||||
line: String,
|
||||
secure: bool,
|
||||
},
|
||||
/// A chat message a player sent (after it was broadcast), for server mods.
|
||||
PlayerChat {
|
||||
sender: u128,
|
||||
text: String,
|
||||
},
|
||||
/// A server log warning (ServerGamePacketListenerImpl's LOGGER.warn).
|
||||
Warn(String),
|
||||
/// A server log error (LOGGER.error).
|
||||
@@ -371,9 +376,21 @@ impl Hub {
|
||||
id
|
||||
}
|
||||
pub fn stack(&mut self, s: &Option<ItemStack>) -> Option<horizon_protocol::item::Stack> {
|
||||
let id = s.as_ref().map(|s| self.item_id(&s.item))?;
|
||||
let item = s.as_ref()?.item.clone();
|
||||
let id = self.item_id(&item);
|
||||
let sync = self.sync.clone();
|
||||
stack_to_network(|_| id, |registry, name| sync.id(registry, name), s)
|
||||
// The stacks inside it (a bundle's contents, a crossbow's projectiles)
|
||||
// have ids of their own.
|
||||
let item_id = |name: &str| {
|
||||
if name == &*item {
|
||||
return id;
|
||||
}
|
||||
sync.builtin
|
||||
.get("minecraft:item")
|
||||
.and_then(|i| i.id(name))
|
||||
.unwrap_or(0)
|
||||
};
|
||||
stack_to_network(item_id, |registry, name| sync.id(registry, name), s)
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, packet: &C) {
|
||||
|
||||
@@ -219,6 +219,10 @@ impl Hub {
|
||||
};
|
||||
let line = format!("<{name}> {}", message.content);
|
||||
self.broadcast_chat(&message, &bound, line, events);
|
||||
events.push(HostEvent::PlayerChat {
|
||||
sender: uuid,
|
||||
text: message.content,
|
||||
});
|
||||
}
|
||||
|
||||
/// handleChatSessionUpdate: a new profile key, validated against the
|
||||
|
||||
@@ -37,6 +37,10 @@ pub struct Menu {
|
||||
pub sent_data: Vec<i16>,
|
||||
}
|
||||
|
||||
/// The y of the key a chest minecart's or chest boat's items are kept under
|
||||
/// while a menu shows them.
|
||||
pub const VEHICLE_KEY_Y: i32 = -100_000;
|
||||
|
||||
/// Where the slots of a container menu live.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Slots {
|
||||
@@ -72,10 +76,12 @@ impl Slots {
|
||||
Slots::Ender => at.into_iter().collect(),
|
||||
_ => self.halves(),
|
||||
};
|
||||
// A vehicle's container is kept under a key, not a block: the game
|
||||
// checks its reach to the vehicle (`isChestVehicleStillValid`).
|
||||
self.valid(world)
|
||||
&& blocks
|
||||
.iter()
|
||||
.all(|k| guest.within_block_range([k.0, k.1, k.2], 4.0))
|
||||
.all(|k| k.1 == VEHICLE_KEY_Y || guest.within_block_range([k.0, k.1, k.2], 4.0))
|
||||
}
|
||||
/// Container slot `n`.
|
||||
pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option<ItemStack> {
|
||||
|
||||
@@ -85,6 +85,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
|
||||
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
|
||||
guest.saved = level.player_extra.clone();
|
||||
guest.seen_credits = level.seen_credits;
|
||||
let extra = level.player_extra.as_ref();
|
||||
let short = |key: &str, default: i64| {
|
||||
extra
|
||||
@@ -166,6 +167,7 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
level.spawn = guest.spawn.map(|s| [s[0] - 0.5, s[1], s[2] - 0.5]);
|
||||
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
|
||||
level.on_ground = guest.on_ground;
|
||||
level.seen_credits = guest.seen_credits;
|
||||
level.player_extra = guest.saved.clone();
|
||||
// The live body's fire, air and fall distance (Entity.saveWithoutId),
|
||||
// or what the file had while the player has no body yet.
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod cooldowns;
|
||||
pub mod crafting;
|
||||
pub mod crash;
|
||||
pub mod creative;
|
||||
pub mod cushion;
|
||||
pub mod data_cmd;
|
||||
pub mod datapack;
|
||||
pub mod debug_chart;
|
||||
|
||||
@@ -113,6 +113,14 @@ mod vertex_tests {
|
||||
assert!((a - b).abs() <= 0.5 / 32768.0, "{a} {b}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn emission_keeps_the_colour_and_the_free_bits() {
|
||||
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
|
||||
assert_eq!(super::emitting(rgb, 0), rgb);
|
||||
assert_eq!(super::emitting(rgb, 9), 0xF656_3412);
|
||||
assert_eq!(super::emitting(rgb, 15), 0xF056_3412);
|
||||
assert_eq!(super::emitting(rgb, 15) | super::EMISSION_BITS, rgb);
|
||||
}
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
@@ -566,6 +574,14 @@ impl Padded {
|
||||
}
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold the light its face emits, for
|
||||
/// HDR output's highlights (`gfx/hdr.rs`): 15 minus the level, so the usual 0xFF means none.
|
||||
/// The byte's other four bits stay set.
|
||||
pub const EMISSION_BITS: u32 = 0x0F00_0000;
|
||||
/// `color` marked as emitting light `level` (0 to 15).
|
||||
pub fn emitting(color: u32, level: u8) -> u32 {
|
||||
color & !EMISSION_BITS | u32::from(15 - level.min(15)) << 24
|
||||
}
|
||||
const UNSET: u32 = u32::MAX;
|
||||
const NO_BIOME: u16 = u16::MAX;
|
||||
/// The `ColorResolver`s `ClientLevel` caches block tints for.
|
||||
@@ -880,7 +896,7 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
face.layer as u16
|
||||
},
|
||||
pack_rgb(rgb),
|
||||
emitting(pack_rgb(rgb), state.emission.max(q.light_emission)),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -227,8 +227,11 @@ pub(super) fn tesselate(
|
||||
} else {
|
||||
0xFF_FFFF
|
||||
};
|
||||
// Lava emits light level 15.
|
||||
let emission = if water { 0 } else { 15 };
|
||||
let color = |by: f32| {
|
||||
pack_rgb([16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32))
|
||||
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
};
|
||||
let height = |p: [i32; 3]| -> f32 {
|
||||
let s = at(p);
|
||||
|
||||
@@ -102,7 +102,7 @@ impl AnimState {
|
||||
pub fn end_tick(&mut self, _swinging: bool) {}
|
||||
}
|
||||
/// Part poses for one draw, keyed by part name.
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Poses {
|
||||
map: HashMap<String, [f32; 9]>,
|
||||
dummy: [f32; 9],
|
||||
@@ -113,20 +113,24 @@ pub struct Poses {
|
||||
}
|
||||
impl Poses {
|
||||
pub fn new(model: &Part) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
let mut poses = Self::default();
|
||||
poses.reset(model);
|
||||
poses
|
||||
}
|
||||
/// Refills this for another model, keeping the map and the sets' capacity:
|
||||
/// a mob draws its body and every equipment layer each frame.
|
||||
pub fn reset(&mut self, model: &Part) {
|
||||
fn walk(p: &Part, map: &mut HashMap<String, [f32; 9]>) {
|
||||
map.insert(p.name.clone(), p.pose);
|
||||
for c in &p.children {
|
||||
walk(c, map);
|
||||
}
|
||||
}
|
||||
walk(model, &mut map);
|
||||
Self {
|
||||
map,
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
}
|
||||
self.map.clear();
|
||||
self.dummy = [0.0; 9];
|
||||
self.skipped.clear();
|
||||
self.hidden.clear();
|
||||
walk(model, &mut self.map);
|
||||
}
|
||||
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
match self.map.get_mut(name) {
|
||||
|
||||
@@ -241,9 +241,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
} else {
|
||||
a.v[SPIKES] += (1.0 - a.v[SPIKES]) * 0.06;
|
||||
}
|
||||
if m.extra.flag && m.extra.counter2 < attack_duration(m) {
|
||||
m.extra.counter2 += 1;
|
||||
}
|
||||
client_tick(m);
|
||||
if in_water {
|
||||
m.air = 300;
|
||||
} else if m.on_ground {
|
||||
@@ -332,6 +330,13 @@ pub fn elder_server_ai_step(m: &mut Mob, cx: &mut Cx) {
|
||||
]);
|
||||
}
|
||||
}
|
||||
/// Guardian.aiStep's visual attack clock, also used by server clients.
|
||||
/// Does not run attack goals, movement, damage or random draws.
|
||||
pub fn client_tick(m: &mut Mob) {
|
||||
if m.extra.flag && m.extra.counter2 < attack_duration(m) {
|
||||
m.extra.counter2 += 1;
|
||||
}
|
||||
}
|
||||
/// The beam's clock for rendering: (attack time with the partial tick,
|
||||
/// attack scale), while a target is locked.
|
||||
pub fn beam(m: &Mob, partial: f32) -> Option<(f32, f32)> {
|
||||
|
||||
@@ -951,21 +951,25 @@ fn draw_normals(
|
||||
return;
|
||||
}
|
||||
let normals = normals.part(p);
|
||||
for poly in part.polygons.iter().filter(|_| !poses.skips(part)) {
|
||||
let normal = poly.vertex_normal(normals);
|
||||
for v in poly.vertices {
|
||||
let pos = m.transform_point3(glam::Vec3::new(v[0], v[1], v[2]) / 16.0);
|
||||
out.push(Vertex {
|
||||
normal,
|
||||
pos: pos.to_array(),
|
||||
uv: [
|
||||
uv[0] + (uv[2] - uv[0]) * v[3],
|
||||
uv[1] + (uv[3] - uv[1]) * v[4],
|
||||
],
|
||||
layer: overlay,
|
||||
color,
|
||||
light,
|
||||
});
|
||||
// `skips` is a name lookup: ask once per part, not once per polygon. A part
|
||||
// hidden this way still draws its children, so only the polygons are gated.
|
||||
if !poses.skips(part) {
|
||||
for poly in &part.polygons {
|
||||
let normal = poly.vertex_normal(normals);
|
||||
for v in poly.vertices {
|
||||
let pos = m.transform_point3(glam::Vec3::new(v[0], v[1], v[2]) / 16.0);
|
||||
out.push(Vertex {
|
||||
normal,
|
||||
pos: pos.to_array(),
|
||||
uv: [
|
||||
uv[0] + (uv[2] - uv[0]) * v[3],
|
||||
uv[1] + (uv[3] - uv[1]) * v[4],
|
||||
],
|
||||
layer: overlay,
|
||||
color,
|
||||
light,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in &part.children {
|
||||
@@ -1094,6 +1098,9 @@ fn render_visible(
|
||||
}
|
||||
}
|
||||
}
|
||||
// One Poses refilled for every layer (armour draws up to seven): it keeps
|
||||
// its maps' capacity.
|
||||
let mut layer_poses = Poses::default();
|
||||
for layer in all_layers {
|
||||
let Some(part) = m.kind.layer(&layer.model).cloned() else {
|
||||
continue;
|
||||
@@ -1101,8 +1108,8 @@ fn render_visible(
|
||||
let Some(uv) = atlas(&layer.texture) else {
|
||||
continue;
|
||||
};
|
||||
let mut poses = Poses::new(&part);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
layer_poses.reset(&part);
|
||||
anim::setup(m, &s, &mut layer_poses, &data.animations);
|
||||
if matches!(m.species(), Species::Villager | Species::ZombieVillager)
|
||||
&& let Some((folder, variant)) = layer
|
||||
.texture
|
||||
@@ -1113,7 +1120,7 @@ fn render_visible(
|
||||
data.villager_hats
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.apply(folder, variant, profession, &mut poses);
|
||||
.apply(folder, variant, profession, &mut layer_poses);
|
||||
}
|
||||
let light = if layer.full_bright {
|
||||
240 | 240 << 8
|
||||
@@ -1128,7 +1135,16 @@ fn render_visible(
|
||||
None => overlay,
|
||||
};
|
||||
let start = out.len();
|
||||
draw(&part, base, &poses, uv, layer.color, light, overlay, out);
|
||||
draw(
|
||||
&part,
|
||||
base,
|
||||
&layer_poses,
|
||||
uv,
|
||||
layer.color,
|
||||
light,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
if layer.full_bright && m.species() != Species::Warden {
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.normal = [0; 4];
|
||||
|
||||
@@ -11,9 +11,15 @@ use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const SERVER_NAME: crate::clipboard::FieldId = ("server.name", 0);
|
||||
const SERVER_ADDRESS: crate::clipboard::FieldId = ("server.address", 0);
|
||||
const SERVER_DIRECT: crate::clipboard::FieldId = ("server.direct", 0);
|
||||
const FRIEND_CODE: crate::clipboard::FieldId = ("friend.code", 0);
|
||||
const FRIEND_RELAY: crate::clipboard::FieldId = ("friend.relay", 0);
|
||||
|
||||
/// The result of pinging one address.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Ping {
|
||||
@@ -587,18 +593,21 @@ impl Screen {
|
||||
}
|
||||
}
|
||||
/// An EditBox and its label: the label at `label_y` (one pixel in, in
|
||||
/// 0xA0A0A0), the 200x20 box at `box_y`.
|
||||
/// 0xA0A0A0), the 200x20 box at `box_y`. The box is the same `EditBox`
|
||||
/// the world screens edit (`Caret`): cursor, selection and scrolling.
|
||||
fn field(
|
||||
atlas: &HudAtlas,
|
||||
batch: &mut HudBatch,
|
||||
caret: &mut crate::ui::Caret,
|
||||
focused_at: Instant,
|
||||
label: &str,
|
||||
value: &str,
|
||||
id: crate::clipboard::FieldId,
|
||||
max: usize,
|
||||
(label_y, box_y): (f32, f32),
|
||||
focused: bool,
|
||||
blink: bool,
|
||||
) {
|
||||
let x = (batch.width.ceil() / 2.0).floor() - 100.0;
|
||||
crate::widgets::target_as([x, box_y, 200.0, 20.0], crate::widgets::Kind::Field);
|
||||
batch.text(
|
||||
atlas,
|
||||
label,
|
||||
@@ -607,35 +616,25 @@ impl Screen {
|
||||
rgba(160, 160, 160, 255),
|
||||
true,
|
||||
);
|
||||
batch.fill(
|
||||
caret.draw(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
box_y,
|
||||
200.0,
|
||||
20.0,
|
||||
if focused {
|
||||
u32::MAX
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
},
|
||||
);
|
||||
batch.fill(atlas, x + 1.0, box_y + 1.0, 198.0, 18.0, rgba(0, 0, 0, 255));
|
||||
let shown = if focused && blink {
|
||||
format!("{value}_")
|
||||
} else {
|
||||
value.to_owned()
|
||||
};
|
||||
batch.text(
|
||||
atlas,
|
||||
&shown,
|
||||
x + 4.0,
|
||||
box_y + 6.0,
|
||||
rgba(224, 224, 224, 255),
|
||||
true,
|
||||
(id, value, max),
|
||||
[x, box_y, 200.0, 20.0],
|
||||
focused,
|
||||
focused_at,
|
||||
);
|
||||
}
|
||||
pub fn draw(&self, atlas: &HudAtlas, batch: &mut HudBatch, blink: bool, cursor: (f32, f32)) {
|
||||
pub fn draw(
|
||||
&self,
|
||||
atlas: &HudAtlas,
|
||||
batch: &mut HudBatch,
|
||||
caret: &mut crate::ui::Caret,
|
||||
focused_at: Instant,
|
||||
cursor: (f32, f32),
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let max = self.max_length();
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
let centered = |batch: &mut HudBatch, text: &str, y: f32, color: u32| {
|
||||
let tw = HudBatch::text_width(atlas, text);
|
||||
@@ -888,20 +887,26 @@ impl Screen {
|
||||
Self::field(
|
||||
atlas,
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
&crate::lang::get_or("manageServer.enterName", "Server Name"),
|
||||
name,
|
||||
SERVER_NAME,
|
||||
max,
|
||||
(53.0, 66.0),
|
||||
*focus == 0,
|
||||
blink,
|
||||
);
|
||||
Self::field(
|
||||
atlas,
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
&crate::lang::get_or("manageServer.enterIp", "Server Address"),
|
||||
address,
|
||||
SERVER_ADDRESS,
|
||||
max,
|
||||
(94.0, 106.0),
|
||||
*focus == 1,
|
||||
blink,
|
||||
);
|
||||
}
|
||||
Self::Direct { address } => {
|
||||
@@ -916,11 +921,14 @@ impl Screen {
|
||||
Self::field(
|
||||
atlas,
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
&crate::lang::get_or("manageServer.enterIp", "Server Address"),
|
||||
address,
|
||||
SERVER_DIRECT,
|
||||
max,
|
||||
(100.0, 116.0),
|
||||
true,
|
||||
blink,
|
||||
);
|
||||
}
|
||||
Self::ConfirmDelete { name, lines, .. } => {
|
||||
@@ -954,20 +962,26 @@ impl Screen {
|
||||
Self::field(
|
||||
atlas,
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
"Invite Code",
|
||||
code,
|
||||
FRIEND_CODE,
|
||||
max,
|
||||
(53.0, 64.0),
|
||||
*focus == 0,
|
||||
blink,
|
||||
);
|
||||
Self::field(
|
||||
atlas,
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
"Relay (optional)",
|
||||
relay,
|
||||
FRIEND_RELAY,
|
||||
max,
|
||||
(94.0, 105.0),
|
||||
*focus == 1,
|
||||
blink,
|
||||
);
|
||||
}
|
||||
Self::Disconnected { reason, retry } => {
|
||||
@@ -1261,6 +1275,19 @@ impl Screen {
|
||||
scroll.release();
|
||||
}
|
||||
}
|
||||
/// The focused box, in GUI pixels: name at 66, address at 106
|
||||
/// (`ManageServerScreen`), direct address at 116, friend fields at 64 and 105.
|
||||
pub fn focused_box(&self, (w, _h): (f32, f32)) -> Option<[f32; 4]> {
|
||||
let y = match self {
|
||||
Self::Edit { focus: 0, .. } => 66.0,
|
||||
Self::Edit { .. } => 106.0,
|
||||
Self::Direct { .. } => 116.0,
|
||||
Self::Friend { focus: 0, .. } => 64.0,
|
||||
Self::Friend { .. } => 105.0,
|
||||
_ => return None,
|
||||
};
|
||||
Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0])
|
||||
}
|
||||
fn focused_field(&mut self) -> Option<&mut String> {
|
||||
match self {
|
||||
Self::Edit {
|
||||
@@ -1297,11 +1324,11 @@ impl Screen {
|
||||
/// The focused text field, its id and maximum length (copy and paste).
|
||||
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
|
||||
let id = match self {
|
||||
Self::Edit { focus: 0, .. } => ("server.name", 0),
|
||||
Self::Edit { .. } => ("server.address", 0),
|
||||
Self::Direct { .. } => ("server.direct", 0),
|
||||
Self::Friend { focus: 0, .. } => ("friend.code", 0),
|
||||
Self::Friend { .. } => ("friend.relay", 0),
|
||||
Self::Edit { focus: 0, .. } => SERVER_NAME,
|
||||
Self::Edit { .. } => SERVER_ADDRESS,
|
||||
Self::Direct { .. } => SERVER_DIRECT,
|
||||
Self::Friend { focus: 0, .. } => FRIEND_CODE,
|
||||
Self::Friend { .. } => FRIEND_RELAY,
|
||||
_ => return None,
|
||||
};
|
||||
let max = self.max_length();
|
||||
@@ -1315,21 +1342,6 @@ impl Screen {
|
||||
128
|
||||
}
|
||||
}
|
||||
pub fn type_text(&mut self, text: &str) {
|
||||
let max = self.max_length();
|
||||
if let Some(field) = self.focused_field() {
|
||||
for c in text.chars().filter(|c| !c.is_control()) {
|
||||
if field.chars().count() < max {
|
||||
field.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn backspace(&mut self) {
|
||||
if let Some(field) = self.focused_field() {
|
||||
field.pop();
|
||||
}
|
||||
}
|
||||
pub fn tab(&mut self) {
|
||||
if let Self::Edit { focus, .. } | Self::Friend { focus, .. } = self {
|
||||
*focus = (*focus + 1) % 2;
|
||||
|
||||
@@ -526,6 +526,12 @@ pub fn encode_block_entity(
|
||||
}
|
||||
}
|
||||
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
|
||||
// CampfireBlockEntity.saveAdditional.
|
||||
BlockEntity::Campfire(c) => {
|
||||
entries.push(("Items", encode_items(&c.items)));
|
||||
entries.push(("CookingTimes", Tag::IntArray(c.progress.to_vec())));
|
||||
entries.push(("CookingTotalTimes", Tag::IntArray(c.total.to_vec())));
|
||||
}
|
||||
BlockEntity::Crafter(c) => {
|
||||
entries.push(("Items", encode_items(&c.items)));
|
||||
entries.push((
|
||||
@@ -676,8 +682,10 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
Tag::Int(n) => *n,
|
||||
_ => continue,
|
||||
};
|
||||
f.recipes_used
|
||||
.insert(id.to_string_lossy(), count.max(0) as u32);
|
||||
// Saves from before the key was namespaced hold both forms of an id.
|
||||
*f.recipes_used
|
||||
.entry(crate::containers::recipe_used_key(&id.to_string_lossy()))
|
||||
.or_insert(0) += count.max(0) as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,6 +712,18 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
}
|
||||
BlockEntity::Sign(sign) => sign.decode(tag),
|
||||
BlockEntity::Sculk(s) => s.load(tag),
|
||||
// CampfireBlockEntity.loadAdditional: missing time arrays stay zeros.
|
||||
BlockEntity::Campfire(c) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 4).into_iter().enumerate() {
|
||||
c.items[slot] = item;
|
||||
}
|
||||
let ints = |key: &str| match tag.get(key) {
|
||||
Some(Tag::IntArray(v)) => std::array::from_fn(|i| v.get(i).copied().unwrap_or(0)),
|
||||
_ => [0; 4],
|
||||
};
|
||||
c.progress = ints("CookingTimes");
|
||||
c.total = ints("CookingTotalTimes");
|
||||
}
|
||||
BlockEntity::Crafter(c) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 9).into_iter().enumerate() {
|
||||
c.items[slot] = item;
|
||||
@@ -734,18 +754,67 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
Some((pos, entity))
|
||||
}
|
||||
|
||||
/// What a dimension's saved chunks depend on: skylight and the build height
|
||||
/// (`DimensionType.minY` and `height`). `Chunk` always holds the Overworld's
|
||||
/// sections; a level saves only the ones inside its own height.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LevelShape {
|
||||
pub sky_light: bool,
|
||||
pub min_y: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
impl LevelShape {
|
||||
pub const OVERWORLD: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: MIN_Y,
|
||||
height: SECTIONS as i32 * 16,
|
||||
};
|
||||
pub const NETHER: Self = Self {
|
||||
sky_light: false,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
};
|
||||
pub const END: Self = Self {
|
||||
sky_light: true,
|
||||
min_y: 0,
|
||||
height: 256,
|
||||
};
|
||||
/// The shape of a dimension folder (`dimensions/minecraft/<name>`, or the
|
||||
/// old `DIM-1` and `DIM1`).
|
||||
pub fn for_dir(root: &Path) -> Self {
|
||||
if root.ends_with("the_nether") || root.ends_with("DIM-1") {
|
||||
Self::NETHER
|
||||
} else if root.ends_with("the_end") || root.ends_with("DIM1") {
|
||||
Self::END
|
||||
} else {
|
||||
Self::OVERWORLD
|
||||
}
|
||||
}
|
||||
fn min_section(self) -> i32 {
|
||||
self.min_y >> 4
|
||||
}
|
||||
fn section_count(self) -> usize {
|
||||
(self.height >> 4) as usize
|
||||
}
|
||||
fn has_section(self, y: i32) -> bool {
|
||||
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
|
||||
}
|
||||
}
|
||||
|
||||
/// A column's sections as `Heightmap.primeHeightmaps` reads them.
|
||||
struct HeightmapView<'a> {
|
||||
sections: &'a [Arc<Section>],
|
||||
tables: &'a SaveTables,
|
||||
min_y: i32,
|
||||
height: i32,
|
||||
}
|
||||
impl HeightmapChunk for HeightmapView<'_> {
|
||||
type State = crate::assets::StateFacts;
|
||||
fn min_y(&self) -> i32 {
|
||||
MIN_Y
|
||||
self.min_y
|
||||
}
|
||||
fn height(&self) -> i32 {
|
||||
SECTIONS as i32 * 16
|
||||
self.height
|
||||
}
|
||||
fn highest_section_y(&self) -> i32 {
|
||||
let top = self
|
||||
@@ -765,7 +834,7 @@ impl HeightmapChunk for HeightmapView<'_> {
|
||||
}
|
||||
}
|
||||
/// Heightmaps kept after world generation (`ChunkStatus.FULL.heightmapsAfter`).
|
||||
fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
|
||||
fn heightmaps(chunk: &Chunk, tables: &SaveTables, shape: LevelShape) -> Tag {
|
||||
let kinds = [
|
||||
HeightmapType::WorldSurface,
|
||||
HeightmapType::OceanFloor,
|
||||
@@ -774,11 +843,13 @@ fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
|
||||
];
|
||||
let mut maps: Vec<Heightmap> = kinds
|
||||
.iter()
|
||||
.filter_map(|k| Heightmap::new(*k, MIN_Y, SECTIONS as i32 * 16).ok())
|
||||
.filter_map(|k| Heightmap::new(*k, shape.min_y, shape.height).ok())
|
||||
.collect();
|
||||
let view = HeightmapView {
|
||||
sections: &chunk.sections,
|
||||
tables,
|
||||
min_y: shape.min_y,
|
||||
height: shape.height,
|
||||
};
|
||||
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().collect();
|
||||
if Heightmap::prime(&view, &mut refs).is_err() {
|
||||
@@ -808,7 +879,12 @@ pub fn client_heightmaps_in(
|
||||
.iter()
|
||||
.filter_map(|(id, k)| Heightmap::new(*k, min_y, height).ok().map(|m| (*id, m)))
|
||||
.collect();
|
||||
let view = HeightmapView { sections, tables };
|
||||
let view = HeightmapView {
|
||||
sections,
|
||||
tables,
|
||||
min_y,
|
||||
height,
|
||||
};
|
||||
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().map(|(_, m)| m).collect();
|
||||
if Heightmap::prime(&view, &mut refs).is_err() {
|
||||
return Vec::new();
|
||||
@@ -834,17 +910,18 @@ fn nibbles(light: &Light, sky: bool) -> Vec<u8> {
|
||||
}
|
||||
|
||||
pub fn encode(chunk: &Chunk, registry: &BlockRegistry, biomes: &Biomes) -> NamedTag {
|
||||
encode_with(chunk, registry, biomes, None, true)
|
||||
encode_with(chunk, registry, biomes, None, LevelShape::OVERWORLD)
|
||||
}
|
||||
/// The chunk NBT vanilla's `SerializableChunkData.write` produces for a full
|
||||
/// chunk. `sky_light` is false in dimensions without skylight.
|
||||
/// chunk in a level of `shape`.
|
||||
pub fn encode_with(
|
||||
chunk: &Chunk,
|
||||
registry: &BlockRegistry,
|
||||
biomes: &Biomes,
|
||||
tables: Option<&SaveTables>,
|
||||
sky_light: bool,
|
||||
shape: LevelShape,
|
||||
) -> NamedTag {
|
||||
let sky_light = shape.sky_light;
|
||||
let mut sections = Vec::new();
|
||||
let air = tables.map_or(0, |t| t.air);
|
||||
// Sky layers below the highest section with light other than 15 are
|
||||
@@ -856,6 +933,9 @@ pub fn encode_with(
|
||||
});
|
||||
for (si, section) in chunk.sections.iter().enumerate() {
|
||||
let y = (MIN_Y >> 4) + si as i32;
|
||||
if !shape.has_section(y) {
|
||||
continue;
|
||||
}
|
||||
// PalettedContainer.pack: distinct states in index order, indices at
|
||||
// the saved width.
|
||||
let packed = section.blocks.pack();
|
||||
@@ -985,7 +1065,7 @@ pub fn encode_with(
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("xPos", Tag::Int(chunk.x)),
|
||||
("zPos", Tag::Int(chunk.z)),
|
||||
("yPos", Tag::Int(MIN_Y >> 4)),
|
||||
("yPos", Tag::Int(shape.min_section())),
|
||||
("Status", string("minecraft:full")),
|
||||
("LastUpdate", Tag::Long(extras.map_or(0, |e| e.last_update))),
|
||||
("isLightOn", Tag::Byte(i8::from(chunk.lit))),
|
||||
@@ -1018,10 +1098,10 @@ pub fn encode_with(
|
||||
s.insert("References".into(), Tag::Compound(refs));
|
||||
}
|
||||
map.entry("PostProcessing".into())
|
||||
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); SECTIONS]));
|
||||
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); shape.section_count()]));
|
||||
}
|
||||
if let Some(t) = tables {
|
||||
put(&mut root, "Heightmaps", heightmaps(chunk, t));
|
||||
put(&mut root, "Heightmaps", heightmaps(chunk, t, shape));
|
||||
let names = |k: u32| t.block_names.get(k as usize).cloned();
|
||||
let (blocks, fluids) = extras.map_or((&[][..], &[][..]), |e| {
|
||||
(&e.block_ticks[..], &e.fluid_ticks[..])
|
||||
@@ -1237,18 +1317,19 @@ pub fn encode_proto(
|
||||
registry: &BlockRegistry,
|
||||
biomes: &Biomes,
|
||||
tables: Option<&SaveTables>,
|
||||
sky_light: bool,
|
||||
shape: LevelShape,
|
||||
) -> NamedTag {
|
||||
let mut root = encode_with(&proto.column, registry, biomes, tables, sky_light);
|
||||
let mut root = encode_with(&proto.column, registry, biomes, tables, shape);
|
||||
let status = if proto.decorated {
|
||||
ChunkStatus::Features
|
||||
} else {
|
||||
ChunkStatus::Terrain
|
||||
};
|
||||
// ProtoChunk.postProcessing: one list per section, packed section offsets.
|
||||
let mut marks = vec![Vec::new(); SECTIONS];
|
||||
let mut marks = vec![Vec::new(); shape.section_count()];
|
||||
for &[x, y, z] in &proto.column.postprocess {
|
||||
if let Some(list) = marks.get_mut(((y - MIN_Y) >> 4) as usize) {
|
||||
let section = usize::try_from((y - shape.min_y) >> 4).ok();
|
||||
if let Some(list) = section.and_then(|s| marks.get_mut(s)) {
|
||||
list.push(Tag::Short(
|
||||
((x & 15) | (y & 15) << 4 | (z & 15) << 8) as i16,
|
||||
));
|
||||
@@ -1562,7 +1643,7 @@ struct Encoding {
|
||||
registry: &'static BlockRegistry,
|
||||
biomes: Arc<Biomes>,
|
||||
tables: Option<Arc<SaveTables>>,
|
||||
sky_light: bool,
|
||||
shape: LevelShape,
|
||||
}
|
||||
impl Record {
|
||||
fn tag(&self) -> Option<Arc<NamedTag>> {
|
||||
@@ -1573,7 +1654,7 @@ impl Record {
|
||||
e.registry,
|
||||
&e.biomes,
|
||||
e.tables.as_deref(),
|
||||
e.sky_light,
|
||||
e.shape,
|
||||
))),
|
||||
Record::Delete => None,
|
||||
}
|
||||
@@ -1610,12 +1691,12 @@ pub struct SaveDir {
|
||||
outstanding: Arc<Outstanding>,
|
||||
/// Set by `Persistent` before the first load.
|
||||
pub tables: OnceLock<Arc<SaveTables>>,
|
||||
/// False in dimensions without skylight (no SkyLight layers are written).
|
||||
pub sky_light: bool,
|
||||
/// The dimension's skylight and height, from its folder name.
|
||||
pub shape: LevelShape,
|
||||
}
|
||||
impl SaveDir {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
let sky_light = !root.ends_with("the_nether") && !root.ends_with("DIM-1");
|
||||
let shape = LevelShape::for_dir(&root);
|
||||
Self {
|
||||
root,
|
||||
regions: Default::default(),
|
||||
@@ -1625,7 +1706,7 @@ impl SaveDir {
|
||||
writer: Mutex::new(None),
|
||||
outstanding: Default::default(),
|
||||
tables: OnceLock::new(),
|
||||
sky_light,
|
||||
shape,
|
||||
}
|
||||
}
|
||||
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
|
||||
@@ -1656,7 +1737,7 @@ impl SaveDir {
|
||||
let root = self.read_record("region", x, z)?;
|
||||
let tables = self.tables.get().map(|t| &**t);
|
||||
let mut chunk = decode_with(&root, registry, biomes, tables)?;
|
||||
load_light(&root.tag, &mut chunk, self.sky_light);
|
||||
load_light(&root.tag, &mut chunk, self.shape.sky_light);
|
||||
Some(chunk)
|
||||
}
|
||||
/// A proto chunk saved before it became final (`decode_proto`).
|
||||
@@ -1681,7 +1762,7 @@ impl SaveDir {
|
||||
let records = protos
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let tag = encode_proto(p, registry, biomes, tables, self.sky_light);
|
||||
let tag = encode_proto(p, registry, biomes, tables, self.shape);
|
||||
((p.column.x, p.column.z), Some(tag))
|
||||
})
|
||||
.collect();
|
||||
@@ -1856,7 +1937,7 @@ impl SaveDir {
|
||||
.map(|c| {
|
||||
(
|
||||
(c.x, c.z),
|
||||
Some(encode_with(c, registry, biomes, tables, self.sky_light)),
|
||||
Some(encode_with(c, registry, biomes, tables, self.shape)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1875,7 +1956,7 @@ impl SaveDir {
|
||||
registry,
|
||||
biomes: biomes.clone(),
|
||||
tables: self.tables.get().cloned(),
|
||||
sky_light: self.sky_light,
|
||||
shape: self.shape,
|
||||
});
|
||||
let records = chunks
|
||||
.iter()
|
||||
@@ -3345,7 +3426,7 @@ impl SaveDir {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
decode_lod_surfaces(&root, assets, biomes, self.sky_light)
|
||||
decode_lod_surfaces(&root, assets, biomes, self.shape.sky_light)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3436,6 +3517,71 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn nether_and_end_chunks_save_with_their_own_height() {
|
||||
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
|
||||
return;
|
||||
};
|
||||
let root = PathBuf::from(root);
|
||||
let assets = crate::assets::Assets::load(&root).expect("assets");
|
||||
let biomes = Biomes::load(&root).expect("biomes");
|
||||
let tables = SaveTables::new(&assets);
|
||||
let rack = assets
|
||||
.registry
|
||||
.default_state("minecraft:netherrack")
|
||||
.unwrap();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in 0..128 {
|
||||
chunk.set(0, y, 0, rack.0);
|
||||
}
|
||||
let saved = encode_with(
|
||||
&chunk,
|
||||
assets.registry,
|
||||
&biomes,
|
||||
Some(&tables),
|
||||
LevelShape::NETHER,
|
||||
);
|
||||
assert_eq!(number(saved.tag.get("yPos")), Some(0));
|
||||
let Some(Tag::List(sections)) = saved.tag.get("sections") else {
|
||||
panic!("no sections");
|
||||
};
|
||||
let ys: Vec<i32> = sections
|
||||
.iter()
|
||||
.filter_map(|s| number(s.get("Y")).map(|y| y as i32))
|
||||
.collect();
|
||||
assert_eq!(ys, (0..16).collect::<Vec<_>>());
|
||||
let Some(Tag::List(post)) = saved.tag.get("PostProcessing") else {
|
||||
panic!("no PostProcessing");
|
||||
};
|
||||
assert_eq!(post.len(), 16);
|
||||
// Heightmap values count from the level's minimum: the column's top
|
||||
// block at y=127 reads 128 (vanilla), not 192 (from the Overworld's -64).
|
||||
let Some(Tag::LongArray(surface)) = saved
|
||||
.tag
|
||||
.get("Heightmaps")
|
||||
.and_then(|h| h.get("WORLD_SURFACE"))
|
||||
else {
|
||||
panic!("no WORLD_SURFACE");
|
||||
};
|
||||
assert_eq!(surface[0] & 0x1ff, 128);
|
||||
let overworld = encode_with(
|
||||
&chunk,
|
||||
assets.registry,
|
||||
&biomes,
|
||||
Some(&tables),
|
||||
LevelShape::OVERWORLD,
|
||||
);
|
||||
assert_eq!(number(overworld.tag.get("yPos")), Some(-4));
|
||||
assert_eq!(
|
||||
LevelShape::for_dir(Path::new("w/dimensions/minecraft/the_end")),
|
||||
LevelShape::END
|
||||
);
|
||||
assert_eq!(
|
||||
LevelShape::for_dir(Path::new("w/DIM-1")),
|
||||
LevelShape::NETHER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_far_surface_reads_builds_and_water_without_gameplay_chunks() {
|
||||
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
|
||||
@@ -3554,7 +3700,7 @@ mod tests {
|
||||
"unfinished proto saves must not suppress complete decoration"
|
||||
);
|
||||
let mut nether = SaveDir::new(dir.clone());
|
||||
nether.sky_light = false;
|
||||
nether.shape = LevelShape::NETHER;
|
||||
let dark = nether.lod_surfaces(-2, 3, &assets, &biomes).unwrap();
|
||||
assert!(
|
||||
dark.iter()
|
||||
@@ -3873,6 +4019,31 @@ mod tests {
|
||||
assert_eq!(back.redstone_signal(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn campfire_block_entity_round_trips() {
|
||||
let mut c = crate::containers::Campfire::default();
|
||||
c.items[1] = Some(crate::items::ItemStack::new("minecraft:beef", 1));
|
||||
c.progress[1] = 120;
|
||||
c.total[1] = 600;
|
||||
let tag = encode_block_entity(
|
||||
(3, 64, -2),
|
||||
&crate::containers::BlockEntity::Campfire(Box::new(c.clone())),
|
||||
"minecraft:soul_campfire",
|
||||
);
|
||||
// One block entity type for both campfires.
|
||||
assert_eq!(text(tag.get("id")).as_deref(), Some("minecraft:campfire"));
|
||||
let (pos, back) = decode_block_entity(&tag).expect("campfire");
|
||||
assert_eq!(pos, (3, 64, -2));
|
||||
let crate::containers::BlockEntity::Campfire(back) = back else {
|
||||
panic!("not a campfire");
|
||||
};
|
||||
assert_eq!(*back, c);
|
||||
// The update tag a server's clients get: the items alone.
|
||||
let update =
|
||||
crate::lan::chunks::entity_tag(&crate::containers::BlockEntity::Campfire(Box::new(c)))
|
||||
.expect("update tag");
|
||||
assert!(update.get("Items").is_some() && update.get("CookingTimes").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn spawner_block_entity_round_trips() {
|
||||
// A dungeon spawner as vanilla saves it (`id` is mob_spawner, not the block).
|
||||
let saved = horizon_world::nbt::parse_snbt(
|
||||
@@ -4382,4 +4553,28 @@ mod tests {
|
||||
};
|
||||
assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3));
|
||||
}
|
||||
|
||||
/// A save written before the key was namespaced holds an id twice, once bare: vanilla
|
||||
/// refuses such a furnace, so a load merges them under the full id.
|
||||
#[test]
|
||||
fn a_furnaces_recipes_used_merges_bare_and_full_ids() {
|
||||
use crate::containers::BlockEntity;
|
||||
let Some(BlockEntity::Furnace(mut f)) = BlockEntity::for_block("minecraft:furnace") else {
|
||||
panic!("a furnace");
|
||||
};
|
||||
f.recipes_used
|
||||
.insert("iron_ingot_from_smelting_iron_ore".to_owned(), 2);
|
||||
f.recipes_used
|
||||
.insert("minecraft:iron_ingot_from_smelting_iron_ore".to_owned(), 3);
|
||||
let tag = encode_block_entity((1, 64, 2), &BlockEntity::Furnace(f), "minecraft:furnace");
|
||||
let Some((_, BlockEntity::Furnace(back))) = decode_block_entity(&tag) else {
|
||||
panic!("a furnace back");
|
||||
};
|
||||
assert_eq!(back.recipes_used.len(), 1);
|
||||
assert_eq!(
|
||||
back.recipes_used
|
||||
.get("minecraft:iron_ingot_from_smelting_iron_ore"),
|
||||
Some(&5)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,3 +134,76 @@ impl SprintState {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn standing() -> Conditions {
|
||||
Conditions {
|
||||
enough_food: true,
|
||||
on_ground: true,
|
||||
..Conditions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn step(state: &mut SprintState, keys: Keys, sprinting: &mut bool) {
|
||||
let current = *sprinting;
|
||||
let mut next = current;
|
||||
state
|
||||
.tick(
|
||||
keys,
|
||||
Conditions {
|
||||
sprinting: current,
|
||||
..standing()
|
||||
},
|
||||
7,
|
||||
|value| {
|
||||
next = value;
|
||||
Ok::<(), ()>(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
*sprinting = next;
|
||||
}
|
||||
|
||||
/// Vanilla sprint is a state. Releasing the sprint key, including on the
|
||||
/// jump tick, keeps it while forward is held; releasing forward stops it.
|
||||
#[test]
|
||||
fn sprint_survives_releasing_the_key_through_a_jump() {
|
||||
let mut state = SprintState::default();
|
||||
let mut sprinting = false;
|
||||
step(
|
||||
&mut state,
|
||||
Keys {
|
||||
forward: true,
|
||||
sprint: true,
|
||||
..Keys::default()
|
||||
},
|
||||
&mut sprinting,
|
||||
);
|
||||
assert!(sprinting);
|
||||
step(
|
||||
&mut state,
|
||||
Keys {
|
||||
forward: true,
|
||||
jump: true,
|
||||
..Keys::default()
|
||||
},
|
||||
&mut sprinting,
|
||||
);
|
||||
assert!(
|
||||
sprinting,
|
||||
"a jump after the sprint key comes up must stay a sprint"
|
||||
);
|
||||
step(
|
||||
&mut state,
|
||||
Keys {
|
||||
jump: true,
|
||||
..Keys::default()
|
||||
},
|
||||
&mut sprinting,
|
||||
);
|
||||
assert!(!sprinting);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ use std::{fs, path::Path, sync::Arc};
|
||||
|
||||
/// Everything the workstation menus read, loaded once with the resources.
|
||||
pub struct StationData {
|
||||
pub enchantments: Enchantments,
|
||||
/// The same registry `Items` carries (`Items::enchantments`).
|
||||
pub enchantments: std::sync::Arc<Enchantments>,
|
||||
pub recipes: StationRecipes,
|
||||
pub brewing: crate::brewing::Brewing,
|
||||
pub trades: crate::merchant::Trades,
|
||||
@@ -25,7 +26,7 @@ pub struct StationData {
|
||||
impl StationData {
|
||||
pub fn load(root: &Path, items: &Items) -> Self {
|
||||
Self {
|
||||
enchantments: Enchantments::load(root),
|
||||
enchantments: std::sync::Arc::clone(&items.enchantments),
|
||||
recipes: StationRecipes::load(root, items),
|
||||
brewing: crate::brewing::Brewing::load(root, items),
|
||||
trades: crate::merchant::Trades::load(root),
|
||||
@@ -174,7 +175,9 @@ pub fn increased_repair_cost(cost: u32) -> u32 {
|
||||
pub fn hover_name(names: &dyn Fn(&str) -> String, stack: &ItemStack) -> String {
|
||||
stack
|
||||
.custom_name()
|
||||
.map_or_else(|| names(&stack.item), str::to_owned)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| stack.item_name())
|
||||
.unwrap_or_else(|| names(&stack.item))
|
||||
}
|
||||
/// `AnvilMenu.createResult`. `name` is the rename box text (None = untouched).
|
||||
pub fn anvil_result(
|
||||
|
||||
@@ -69,6 +69,8 @@ struct TerrainOut {
|
||||
@location(2) color: vec4<f32>,
|
||||
@location(3) spherical: f32,
|
||||
@location(4) cylindrical: f32,
|
||||
// The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`).
|
||||
@location(5) @interpolate(flat) emission: u32,
|
||||
}
|
||||
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
|
||||
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
|
||||
@@ -121,7 +123,8 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
let block = max(f32(light.y), dynamic_block_light(pos));
|
||||
out.color = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
|
||||
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
|
||||
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
|
||||
out.spherical = length(pos);
|
||||
out.cylindrical = max(length(pos.xz), abs(pos.y));
|
||||
return out;
|
||||
@@ -140,7 +143,10 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
return apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
// Alpha 0 tells HDR output (hdr.wgsl) a light-emitting block's bright pixels
|
||||
// may shine. Nothing else reads the frame's alpha.
|
||||
let emissive = select(1.0, 0.0, in.emission > 0u);
|
||||
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
|
||||
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user