mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
This commit is contained in:
commit
552f2c1b4b
543 files changed
+38218
-2561
No files matched your search
@@ -234,14 +234,15 @@ jobs:
|
||||
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
- name: Check launcher DLL imports and command-line startup
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
# bash, as launcher.yml's check on the same runner: the launcher is a GUI-subsystem program, and a pwsh
|
||||
# `$output = & launcher --version` read no output from it (run 42217014588).
|
||||
shell: bash
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
|
||||
python ../tools/check_imports.py $launcher
|
||||
$output = & $launcher --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
|
||||
Write-Output ($output -join "`n")
|
||||
launcher=target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
python ../tools/check_imports.py "$launcher"
|
||||
out=$("$launcher" --version)
|
||||
echo "$out"
|
||||
[[ "$out" == "horizon-launcher "* ]] || { echo "::error::horizon-launcher --version printed '$out'"; exit 1; }
|
||||
- name: Build a native-mod game with the launcher's managed toolchain
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
|
||||
Generated
+199
-3
@@ -153,6 +153,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "arraydeque"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -485,6 +491,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@@ -540,6 +547,12 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -946,6 +959,18 @@ version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1105,8 +1130,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1135,6 +1162,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gilrs"
|
||||
version = "0.11.2"
|
||||
@@ -1264,6 +1301,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -1660,10 +1706,15 @@ dependencies = [
|
||||
name = "horizon-mod-deps"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"gif",
|
||||
"mysql",
|
||||
"quinn",
|
||||
"redis",
|
||||
"rusqlite",
|
||||
"rusty-opus",
|
||||
"snap",
|
||||
"tungstenite",
|
||||
"yaml-rust2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2268,6 +2319,17 @@ dependencies = [
|
||||
"redox_syscall 0.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev-sys"
|
||||
version = "0.1.4"
|
||||
@@ -2323,6 +2385,12 @@ version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.14.0"
|
||||
@@ -2450,6 +2518,7 @@ dependencies = [
|
||||
"btoi",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
@@ -2484,7 +2553,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"pp-rs",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-ident",
|
||||
@@ -2498,7 +2567,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -3354,6 +3423,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"socket2 0.6.5",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.21",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.5",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -3401,6 +3526,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -3539,12 +3673,32 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.41.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3619,6 +3773,7 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3883,6 +4038,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "snap"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -4198,13 +4359,23 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4274,6 +4445,9 @@ name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
@@ -4412,6 +4586,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
@@ -4675,6 +4855,12 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "30.0.1"
|
||||
@@ -4728,7 +4914,7 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"wgpu-core-deps-apple",
|
||||
@@ -5471,6 +5657,16 @@ version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"hashlink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
|
||||
-10
@@ -410,16 +410,6 @@ The panel's later features (bans, worlds, server.properties import, logs and cra
|
||||
|
||||
</details>
|
||||
|
||||
#### Ely.by and other sign-in providers
|
||||
|
||||
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Join servers on older versions
|
||||
|
||||
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Third-party notices
|
||||
|
||||
## Catnip / Ponder
|
||||
|
||||
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
|
||||
UV rules of Catnip's `FluidRenderHelper` from Ponder 1.0.82 for Minecraft 1.21.1
|
||||
(`net.createmod.ponder:ponder-neoforge:1.0.82+mc1.21.1`). Copyright (c) 2022 The Create Team,
|
||||
licensed under MIT (`LICENSE_Ponder` in that artifact); the full license heads the Rust file.
|
||||
The native renderer uses Horizon's own block-sprite arrays, pose and light packing. The
|
||||
development-only oracle calls the unchanged helper from the player's local artifact. No
|
||||
original classes, game shaders, textures or prepared resources are distributed here.
|
||||
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
@@ -212,3 +222,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
## Just Enough Items
|
||||
|
||||
The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`.
|
||||
+136
-12
@@ -90,6 +90,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--seed N default 12345
|
||||
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
|
||||
--pvp B fight bots also attack each other (true or false); default true
|
||||
--crits fight bots swing at a sword's full strength (every 13 ticks) and jump into every
|
||||
other swing, landing it while falling: a critical hit, then a sweep from the
|
||||
ground (give them swords); for PvP benches (crit and sweep particles)
|
||||
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
|
||||
--brand B the client brand sent in configuration (minecraft:brand); default none
|
||||
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
|
||||
@@ -165,6 +168,8 @@ struct Options {
|
||||
attack_after: f64,
|
||||
attack_rounds: u32,
|
||||
no_swing: bool,
|
||||
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
|
||||
crits: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
no_loaded: bool,
|
||||
@@ -202,6 +207,7 @@ fn options() -> Result<Options, String> {
|
||||
attack_after: 5.0,
|
||||
attack_rounds: u32::MAX,
|
||||
no_swing: false,
|
||||
crits: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
no_loaded: false,
|
||||
@@ -230,6 +236,10 @@ fn options() -> Result<Options, String> {
|
||||
o.no_swing = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--crits" {
|
||||
o.crits = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-loaded" {
|
||||
o.no_loaded = true;
|
||||
continue;
|
||||
@@ -532,11 +542,84 @@ fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// A sword's attack speed is 1.6 a second: full strength 20 / 1.6 = 12.5 ticks after a swing.
|
||||
const SWORD_COOLDOWN_TICKS: u32 = 13;
|
||||
/// `LivingEntity.getJumpPower`: a jump's first upward motion, in blocks a tick.
|
||||
const JUMP_POWER: f64 = 0.42;
|
||||
|
||||
/// `--crits`: a fight bot that swings at a sword's full strength and jumps into every other swing, so
|
||||
/// it lands while falling (`Player.attack`'s critical hit: falling, not on the ground, full strength),
|
||||
/// and the next one from the ground (a sweep: on the ground, full strength, a sword).
|
||||
#[derive(Default)]
|
||||
struct Crits {
|
||||
/// Vertical motion while in the air (`LivingEntity.travel`: minus 0.08 a tick, times 0.98).
|
||||
jump: Option<f64>,
|
||||
ground: f64,
|
||||
/// The next swing is the jumped one.
|
||||
crit_next: bool,
|
||||
}
|
||||
|
||||
impl Crits {
|
||||
fn airborne(&self) -> bool {
|
||||
self.jump.is_some()
|
||||
}
|
||||
|
||||
/// One tick: walk up to the target and, with a full cooldown, strike; jump ahead of a crit's swing.
|
||||
fn tick(
|
||||
&mut self,
|
||||
pos: &mut [f64; 3],
|
||||
target: (i32, [f64; 3]),
|
||||
since: &mut u32,
|
||||
) -> Option<i32> {
|
||||
if let Some(v) = self.jump {
|
||||
pos[1] += v;
|
||||
if pos[1] <= self.ground {
|
||||
pos[1] = self.ground;
|
||||
self.jump = None;
|
||||
} else {
|
||||
self.jump = Some((v - 0.08) * 0.98);
|
||||
}
|
||||
} else {
|
||||
self.ground = pos[1];
|
||||
// A jump takes 6 ticks to its top: start it so the cooldown ends on the way down.
|
||||
if self.crit_next && *since + 8 >= SWORD_COOLDOWN_TICKS {
|
||||
self.jump = Some(JUMP_POWER);
|
||||
}
|
||||
}
|
||||
let falling = self.jump.is_some_and(|v| v < 0.0);
|
||||
if self.crit_next && !falling {
|
||||
*since = since.saturating_add(1);
|
||||
fight_step(pos, target.1);
|
||||
return None;
|
||||
}
|
||||
let hit = fight_tick(pos, target, since, SWORD_COOLDOWN_TICKS);
|
||||
if hit.is_some() {
|
||||
self.crit_next = !self.crit_next;
|
||||
}
|
||||
hit
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
|
||||
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
|
||||
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
|
||||
fn fight_tick(
|
||||
pos: &mut [f64; 3],
|
||||
target: (i32, [f64; 3]),
|
||||
since_attack: &mut u32,
|
||||
cooldown: u32,
|
||||
) -> Option<i32> {
|
||||
*since_attack = since_attack.saturating_add(1);
|
||||
let (id, at) = target;
|
||||
fight_step(pos, at);
|
||||
if *since_attack >= cooldown && distance(*pos, at) <= ATTACK_REACH {
|
||||
*since_attack = 0;
|
||||
return Some(id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Walk toward `at`, stopping `CLOSE_IN` short of it.
|
||||
fn fight_step(pos: &mut [f64; 3], at: [f64; 3]) {
|
||||
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
|
||||
let horizontal = dx.hypot(dz);
|
||||
if horizontal > CLOSE_IN {
|
||||
@@ -544,11 +627,6 @@ fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u3
|
||||
pos[0] += dx / horizontal * step;
|
||||
pos[2] += dz / horizontal * step;
|
||||
}
|
||||
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
|
||||
*since_attack = 0;
|
||||
return Some(id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
|
||||
@@ -1124,6 +1202,7 @@ fn run_bot(
|
||||
spawn[1] + o.height
|
||||
};
|
||||
let mut since_attack = ATTACK_COOLDOWN_TICKS;
|
||||
let mut crits = Crits::default();
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
@@ -1181,11 +1260,16 @@ fn run_bot(
|
||||
Behaviour::Fight => {
|
||||
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
|
||||
if let Some(near) = near {
|
||||
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
|
||||
let hit = if o.crits {
|
||||
crits.tick(&mut pos, near, &mut since_attack)
|
||||
} else {
|
||||
fight_tick(&mut pos, near, &mut since_attack, ATTACK_COOLDOWN_TICKS)
|
||||
};
|
||||
if let Some(victim) = hit {
|
||||
// The client sends the attack, then swings the arm.
|
||||
let sent = send(
|
||||
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
|
||||
) && send(raw(sb::PUNCH, |w| w.varint(0)));
|
||||
) && send(play::Serverbound::Punch(Empty).encode());
|
||||
if sent {
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -1300,9 +1384,10 @@ fn run_bot(
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
// Fight bots stand on the ground; the others fly.
|
||||
// Fight bots stand on the ground (but in a --crits jump); the others fly.
|
||||
flags: u8::from(
|
||||
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
|
||||
(matches!(behaviour, Behaviour::Fight | Behaviour::Stand) && !crits.airborne())
|
||||
|| o.ground,
|
||||
),
|
||||
})
|
||||
.encode(),
|
||||
@@ -1600,7 +1685,9 @@ mod tests {
|
||||
let target = (3, [1.0, 64.0, 0.0]);
|
||||
let mut since = ATTACK_COOLDOWN_TICKS;
|
||||
let hits: Vec<u32> = (0..40)
|
||||
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
|
||||
.filter_map(|tick| {
|
||||
fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).map(|_| tick)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
|
||||
assert!(
|
||||
@@ -1617,7 +1704,7 @@ mod tests {
|
||||
let mut since = ATTACK_COOLDOWN_TICKS;
|
||||
let mut first = None;
|
||||
for tick in 0..100 {
|
||||
if fight_tick(&mut pos, target, &mut since).is_some() {
|
||||
if fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).is_some() {
|
||||
first = Some(tick);
|
||||
break;
|
||||
}
|
||||
@@ -1627,6 +1714,43 @@ mod tests {
|
||||
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crits_alternate_a_falling_swing_and_a_grounded_one() {
|
||||
let mut pos = [0.0, 64.0, 0.0];
|
||||
let target = (3, [1.0, 64.0, 0.0]);
|
||||
let (mut since, mut crits) = (SWORD_COOLDOWN_TICKS, Crits::default());
|
||||
let mut last_y = pos[1];
|
||||
let mut hits = Vec::new();
|
||||
for tick in 0..120u32 {
|
||||
let hit = crits.tick(&mut pos, target, &mut since);
|
||||
if hit.is_some() {
|
||||
hits.push((tick, crits_state(pos[1], last_y)));
|
||||
}
|
||||
last_y = pos[1];
|
||||
}
|
||||
assert!(hits.len() >= 6, "{hits:?}");
|
||||
for (i, w) in hits.windows(2).enumerate() {
|
||||
assert!(
|
||||
w[1].0 - w[0].0 >= SWORD_COOLDOWN_TICKS,
|
||||
"full strength each time: {hits:?}"
|
||||
);
|
||||
assert_ne!(w[0].1, w[1].1, "swing {i} and the next alternate: {hits:?}");
|
||||
}
|
||||
// A crit lands in the air, on the way down; a sweep from the ground.
|
||||
assert!(hits.iter().any(|h| h.1 == "falling") && hits.iter().any(|h| h.1 == "ground"));
|
||||
assert!(pos[1] >= 64.0);
|
||||
}
|
||||
|
||||
fn crits_state(y: f64, before: f64) -> &'static str {
|
||||
if y == 64.0 {
|
||||
"ground"
|
||||
} else if y < before {
|
||||
"falling"
|
||||
} else {
|
||||
"rising"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentiles_use_nearest_rank() {
|
||||
assert_eq!(percentile(&[], 50), 0);
|
||||
|
||||
@@ -393,38 +393,17 @@ impl FullscreenState {
|
||||
let reapply = self.set_exclusive(exclusive);
|
||||
self.set_preferred_mode(preferred);
|
||||
let mode_change = !in_video_screen && self.take_mode_change();
|
||||
if wanted && window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => enter_fullscreen(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
} else {
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => {
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
}
|
||||
None if reapply || mode_change => {
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
fullscreen_request(window, exclusive, preferred.as_ref()),
|
||||
),
|
||||
None if reapply || mode_change => enter_fullscreen(
|
||||
window,
|
||||
fullscreen_request(window, exclusive, preferred.as_ref()),
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,6 +581,34 @@ fn monitor_current_mode(
|
||||
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
|
||||
}
|
||||
|
||||
/// `window.set_fullscreen(Some(fullscreen))`. On Windows a maximized window leaves its maximized
|
||||
/// state first: winit keeps a maximized window's client area inside the work area, so a
|
||||
/// borderless fullscreen kept the taskbar over its bottom and pushed its top off screen (#9984).
|
||||
pub fn enter_fullscreen(window: &winit::window::Window, fullscreen: winit::window::Fullscreen) {
|
||||
#[cfg(target_os = "windows")]
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen));
|
||||
}
|
||||
|
||||
/// Borderless fullscreen on the window's own monitor (the primary one when it has none).
|
||||
pub fn borderless_fullscreen(window: &winit::window::Window) -> winit::window::Fullscreen {
|
||||
winit::window::Fullscreen::Borderless(
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor()),
|
||||
)
|
||||
}
|
||||
|
||||
/// A window created fullscreen that the system also marked maximized enters it again
|
||||
/// unmaximized ([`enter_fullscreen`]); only Windows does that.
|
||||
pub fn settle_created_fullscreen(window: &winit::window::Window, fullscreen: bool) {
|
||||
if cfg!(target_os = "windows") && fullscreen && window.is_maximized() {
|
||||
enter_fullscreen(window, borderless_fullscreen(window));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
|
||||
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
|
||||
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
|
||||
|
||||
@@ -66,6 +66,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
assert!(!renderer.pack_entity_shadows(), "no selected pack");
|
||||
renderer.set_shader_pack(0, None, &[]).unwrap();
|
||||
|
||||
@@ -224,6 +224,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
settings.translucent_batching,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
drop(pixels);
|
||||
render.pixels.pack();
|
||||
|
||||
@@ -126,6 +126,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
assert!(
|
||||
|
||||
@@ -261,6 +261,40 @@ pub struct Audio {
|
||||
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
/// The look at the output devices running on its own thread, until a tick
|
||||
/// takes its answer: listing them can take 100 ms (#11490).
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: Option<PendingProbe>,
|
||||
}
|
||||
/// What one look at the output devices found: the system default, the
|
||||
/// fingerprint, and the device list when those say it may have changed.
|
||||
#[cfg(feature = "audio")]
|
||||
struct DeviceProbe {
|
||||
default: Option<String>,
|
||||
cards: Option<String>,
|
||||
devices: Option<Vec<String>>,
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
type PendingProbe = Arc<Mutex<Option<DeviceProbe>>>;
|
||||
/// Looks at the output devices as `should_change_device` did on the render
|
||||
/// thread: the device list only when the default or the fingerprint moved.
|
||||
#[cfg(feature = "audio")]
|
||||
fn probe_devices(default_seen: Option<String>, cards_seen: Option<String>) -> DeviceProbe {
|
||||
let default = default_output_name();
|
||||
let cards = card_fingerprint();
|
||||
// Listing the devices probes every one (on Linux every built-in PCM,
|
||||
// printing ALSA's errors for the plugins this system lacks; on macOS
|
||||
// every Core Audio device, about 90 ms with twenty of them; on Windows
|
||||
// every endpoint, with no fingerprint to skip it); only the fingerprint's
|
||||
// devices can change the list, and it is cheap.
|
||||
let devices = (default != default_seen
|
||||
|| cards_changed(cards_seen.as_deref(), cards.as_deref()))
|
||||
.then(output_devices);
|
||||
DeviceProbe {
|
||||
default,
|
||||
cards,
|
||||
devices,
|
||||
}
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
|
||||
use symphonia::core::{
|
||||
@@ -1051,6 +1085,8 @@ impl Audio {
|
||||
devices_seen: devices,
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -1211,6 +1247,8 @@ impl Audio {
|
||||
devices_seen: self.devices_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
/// Sync the volume options, refreshing the categories that changed
|
||||
@@ -1232,7 +1270,8 @@ impl Audio {
|
||||
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
|
||||
/// default changed (with no device chosen in the options), or the chosen
|
||||
/// device came back; the output is then opened again. The devices are looked
|
||||
/// at once a second.
|
||||
/// at once a second, on a thread of their own: a later tick reads what it
|
||||
/// found.
|
||||
#[cfg(feature = "audio")]
|
||||
fn should_change_device(&mut self) -> bool {
|
||||
// A splitscreen share plays through the first player's output: it leaves the device,
|
||||
@@ -1246,29 +1285,54 @@ impl Audio {
|
||||
};
|
||||
if lost {
|
||||
log::info!("Horizon: audio device was lost");
|
||||
self.device_probe = None;
|
||||
self.default_seen = default_output_name();
|
||||
self.cards_seen = card_fingerprint();
|
||||
self.devices_seen = output_devices();
|
||||
return true;
|
||||
}
|
||||
if let Some(pending) = &self.device_probe {
|
||||
let Some(probe) = pending.lock().ok().and_then(|mut found| found.take()) else {
|
||||
return false;
|
||||
};
|
||||
self.device_probe = None;
|
||||
return self.devices_changed(probe, current);
|
||||
}
|
||||
self.device_ticks += 1;
|
||||
if self._stream.is_none() || self.device_ticks < 20 {
|
||||
return false;
|
||||
}
|
||||
self.device_ticks = 0;
|
||||
let default = default_output_name();
|
||||
let cards = card_fingerprint();
|
||||
// Listing the devices probes every one (on Linux every built-in PCM,
|
||||
// printing ALSA's errors for the plugins this system lacks; on macOS
|
||||
// every Core Audio device, about 90 ms with twenty of them); only the
|
||||
// fingerprint's devices can change the list, and it is cheap.
|
||||
if default == self.default_seen
|
||||
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
|
||||
{
|
||||
return false;
|
||||
let pending = PendingProbe::default();
|
||||
let out = Arc::clone(&pending);
|
||||
let seen = (self.default_seen.clone(), self.cards_seen.clone());
|
||||
let started = std::thread::Builder::new()
|
||||
.name("audio-devices".into())
|
||||
.spawn(move || {
|
||||
let probe = probe_devices(seen.0, seen.1);
|
||||
if let Ok(mut found) = out.lock() {
|
||||
*found = Some(probe);
|
||||
}
|
||||
});
|
||||
match started {
|
||||
Ok(_) => self.device_probe = Some(pending),
|
||||
Err(e) => log::warn!("Horizon: could not look at the audio devices: {e}"),
|
||||
}
|
||||
false
|
||||
}
|
||||
/// The rest of `shouldChangeDevice` with what a look at the devices found,
|
||||
/// `current` being the device playing now.
|
||||
#[cfg(feature = "audio")]
|
||||
fn devices_changed(&mut self, probe: DeviceProbe, current: String) -> bool {
|
||||
let DeviceProbe {
|
||||
default,
|
||||
cards,
|
||||
devices,
|
||||
} = probe;
|
||||
let Some(devices) = devices else {
|
||||
return false;
|
||||
};
|
||||
self.cards_seen = cards;
|
||||
let devices = output_devices();
|
||||
// Only a change in the device lists is looked at, as in vanilla: a
|
||||
// preferred device that is listed but cannot open is not retried.
|
||||
if default == self.default_seen && devices == self.devices_seen {
|
||||
|
||||
@@ -538,6 +538,11 @@ impl BlockEntities {
|
||||
});
|
||||
Arc::clone(&self.tickers)
|
||||
}
|
||||
/// How many block entities tick (`Level.blockEntityTickers`, the slots still live), as of
|
||||
/// the last [`Self::tickers`]: what a diagnostics mod counts.
|
||||
pub fn ticking_len(&self) -> usize {
|
||||
self.live.len()
|
||||
}
|
||||
/// Whether a slot from `tickers` still ticks.
|
||||
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
|
||||
self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos)
|
||||
|
||||
@@ -1193,6 +1193,10 @@ impl SuggestLayout {
|
||||
/// the suggestion list, Tab completion, history).
|
||||
pub struct ChatScreen {
|
||||
pub input: TextField,
|
||||
/// `ChatScreen.isDraft`: the box opened with the draft saved last time and
|
||||
/// has not been edited since. A draft draws grey italic, and the first
|
||||
/// Backspace clears the whole box instead of one character.
|
||||
is_draft: bool,
|
||||
width: i32,
|
||||
height: i32,
|
||||
history_buffer: String,
|
||||
@@ -1300,11 +1304,13 @@ fn truncate_utf16(s: &str, max_units: usize) -> String {
|
||||
s.to_owned()
|
||||
}
|
||||
impl ChatScreen {
|
||||
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
|
||||
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`,
|
||||
/// which is a draft when `is_draft` (`ChatComponent.createScreen`).
|
||||
pub fn new(
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
initial: &str,
|
||||
is_draft: bool,
|
||||
history_len: usize,
|
||||
width: i32,
|
||||
height: i32,
|
||||
@@ -1317,6 +1323,7 @@ impl ChatScreen {
|
||||
input.set_value(m, initial);
|
||||
let mut s = Self {
|
||||
input,
|
||||
is_draft,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
@@ -1353,6 +1360,7 @@ impl ChatScreen {
|
||||
input.set_value(m, command);
|
||||
let mut s = Self {
|
||||
input,
|
||||
is_draft: false,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
@@ -1382,7 +1390,8 @@ impl ChatScreen {
|
||||
self.input.set_value(m, command);
|
||||
self.edited(m, c);
|
||||
}
|
||||
/// `ChatScreen.resize`: a new screen with the current text.
|
||||
/// `ChatScreen.resize`: a new screen with the current text, still a draft
|
||||
/// while it has not been edited (`ChatScreen.resize` keeps the field).
|
||||
pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) {
|
||||
if (width, height) != (self.width, self.height) {
|
||||
let value = self.input.value.clone();
|
||||
@@ -1390,7 +1399,16 @@ impl ChatScreen {
|
||||
let abilities = self.abilities;
|
||||
let message = std::mem::take(&mut self.input.message);
|
||||
*self = if self.layout == SuggestLayout::CHAT {
|
||||
Self::new(m, c, &value, self.history_pos, width, height, abilities)
|
||||
Self::new(
|
||||
m,
|
||||
c,
|
||||
&value,
|
||||
self.is_draft,
|
||||
self.history_pos,
|
||||
width,
|
||||
height,
|
||||
abilities,
|
||||
)
|
||||
} else {
|
||||
Self::command_block(m, c, &value, width, height)
|
||||
};
|
||||
@@ -1402,6 +1420,7 @@ impl ChatScreen {
|
||||
fn edited(&mut self, m: &Measure, c: &Commands) {
|
||||
self.allow_suggestions = true;
|
||||
self.update_command_info(m, c);
|
||||
self.is_draft = false;
|
||||
}
|
||||
/// Ctrl+A/C/V/X in the input box.
|
||||
pub fn shortcut(
|
||||
@@ -1415,6 +1434,24 @@ impl ChatScreen {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
/// Edit the existing input and refresh its command responder when it changes.
|
||||
pub fn edit_input(&mut self, m: &Measure, c: &Commands, f: impl FnOnce(&mut TextField)) {
|
||||
let before = (
|
||||
self.input.value.clone(),
|
||||
self.input.cursor,
|
||||
self.input.highlight,
|
||||
);
|
||||
f(&mut self.input);
|
||||
if before
|
||||
!= (
|
||||
self.input.value.clone(),
|
||||
self.input.cursor,
|
||||
self.input.highlight,
|
||||
)
|
||||
{
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
/// Typed text (`charTyped` per character).
|
||||
pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) {
|
||||
for ch in text.chars() {
|
||||
@@ -1729,6 +1766,14 @@ impl ChatScreen {
|
||||
if self.suggestions_key(m, c, key, shift) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
// ChatScreen.keyPressed: while the box holds an unedited draft, the
|
||||
// first Backspace clears all of it rather than one character.
|
||||
if self.is_draft && key == K::Backspace {
|
||||
self.input.set_value(m, "");
|
||||
self.is_draft = false;
|
||||
self.edited(m, c);
|
||||
return ChatAction::None;
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
@@ -1862,8 +1907,13 @@ impl ChatScreen {
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
/// `CommandSuggestions.formatText`: argument colours over the input.
|
||||
/// `CommandSuggestions.formatText`: argument colours over the input. The
|
||||
/// draft formatter (`ChatScreen.formatChat`) is registered before this one
|
||||
/// and wins while the draft is unedited: the whole text is grey italic.
|
||||
fn format(&self, value: &str, offset: usize) -> Text {
|
||||
if self.is_draft {
|
||||
return Text::literal(value).with_color(text::GRAY).italic();
|
||||
}
|
||||
let gray = Style::color(text::GRAY);
|
||||
let Some(parse) = &self.parse else {
|
||||
return Text::literal(value);
|
||||
|
||||
@@ -201,7 +201,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
|
||||
assert_eq!(link.component()["click_event"]["command"], expected);
|
||||
let mut chat = Chat::default();
|
||||
add(&mut chat, &font, Text::literal("Found at ").append(link));
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", false, 0, 640, 360, full());
|
||||
let x = 4.0 + f64::from(font.width("Found at "));
|
||||
assert!(!screen.click(&m, &commands, x, 315.0));
|
||||
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
|
||||
@@ -242,6 +242,7 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
|
||||
&m,
|
||||
&commands,
|
||||
"unfinished draft",
|
||||
false,
|
||||
history.len(),
|
||||
640,
|
||||
360,
|
||||
@@ -263,6 +264,93 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatScreen.isDraft`: the box opens on the draft kept from last time
|
||||
/// (`ChatComponent.createScreen` passes `true`, which `ChatScreen.init`
|
||||
/// stores), so the text draws grey italic through `ChatScreen.formatChat`
|
||||
/// and the first Backspace clears the whole draft instead of one character.
|
||||
/// Editing the draft (`onEdited`) ends both.
|
||||
#[test]
|
||||
fn a_restored_draft_is_grey_italic_and_one_backspace_clears_it() {
|
||||
use winit::keyboard::KeyCode::Backspace;
|
||||
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
// The input's own text as the game draws it: x 4, y height - 12.
|
||||
let input_text = |screen: &mut ChatScreen| -> Text {
|
||||
screen
|
||||
.draw(&m, (0, 0), 0, i32::MIN)
|
||||
.into_iter()
|
||||
.find_map(|draw| match draw {
|
||||
Draw::Text {
|
||||
text, x: 4, y: 348, ..
|
||||
} => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the input draws its text")
|
||||
};
|
||||
let draft_style = |t: &Text| {
|
||||
t.spans
|
||||
.iter()
|
||||
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
|
||||
assert_eq!(screen.value(), "half-written");
|
||||
assert!(
|
||||
draft_style(&input_text(&mut screen)),
|
||||
"the restored draft draws grey italic"
|
||||
);
|
||||
// Typing into the draft (`onEdited`) ends the draft style, and Backspace
|
||||
// eats one character again.
|
||||
screen.type_text(&m, &c, "!");
|
||||
assert_eq!(screen.value(), "half-written!");
|
||||
let typed = input_text(&mut screen);
|
||||
assert!(
|
||||
!typed.spans.iter().any(|s| s.style.is_italic()),
|
||||
"an edited draft is not grey italic"
|
||||
);
|
||||
assert_eq!(
|
||||
screen.key(&m, &c, &[], Backspace, false, false),
|
||||
ChatAction::None
|
||||
);
|
||||
assert_eq!(screen.value(), "half-written");
|
||||
// A draft left alone clears whole on its first Backspace.
|
||||
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
|
||||
assert_eq!(
|
||||
screen.key(&m, &c, &[], Backspace, false, false),
|
||||
ChatAction::None
|
||||
);
|
||||
assert_eq!(screen.value(), "", "the first Backspace clears the draft");
|
||||
// The flag is gone with it: typing is ordinary text, and Backspace eats
|
||||
// one character.
|
||||
screen.type_text(&m, &c, "hi");
|
||||
assert_eq!(screen.value(), "hi");
|
||||
let typed = input_text(&mut screen);
|
||||
assert!(!typed.spans.iter().any(|s| s.style.is_italic()));
|
||||
assert_eq!(
|
||||
screen.key(&m, &c, &[], Backspace, false, false),
|
||||
ChatAction::None
|
||||
);
|
||||
assert_eq!(screen.value(), "h");
|
||||
// A box opened on ordinary text deletes one character at a time.
|
||||
let mut screen = ChatScreen::new(&m, &c, "half-written", false, 0, 640, 360, full());
|
||||
assert!(!draft_style(&input_text(&mut screen)));
|
||||
assert_eq!(
|
||||
screen.key(&m, &c, &[], Backspace, false, false),
|
||||
ChatAction::None
|
||||
);
|
||||
assert_eq!(screen.value(), "half-writte");
|
||||
// A command draft is grey italic as a whole: `formatChat` is registered
|
||||
// before `CommandSuggestions.formatText`, so it wins over the argument
|
||||
// colours.
|
||||
let mut screen = ChatScreen::new(&m, &c, "/give @s stone", true, 0, 640, 360, full());
|
||||
assert!(draft_style(&input_text(&mut screen)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
let font = font();
|
||||
@@ -283,7 +371,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
dispatcher,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
screen
|
||||
};
|
||||
@@ -333,7 +421,7 @@ fn chat_completes_the_servers_custom_completions_with_the_players() {
|
||||
dispatcher,
|
||||
registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
// Chat shows no list by itself: Tab shows it, Tab again uses it.
|
||||
for _ in 0..2 {
|
||||
@@ -381,7 +469,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
let tab = |screen: &mut ChatScreen| {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
let (request, sent) = screen.take_request().expect("the server is asked");
|
||||
assert_eq!(sent, "/bossbar get pt");
|
||||
@@ -403,7 +491,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
dispatcher: local,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
assert_eq!(screen.take_request(), None);
|
||||
}
|
||||
@@ -473,7 +561,7 @@ fn the_chat_option_decides_what_may_be_sent() {
|
||||
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
|
||||
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
|
||||
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, abilities);
|
||||
screen.type_text(&m, &c, line);
|
||||
match enter(&mut screen) {
|
||||
ChatAction::Submit(sent) => {
|
||||
@@ -499,7 +587,16 @@ fn restricted_input_says_why_it_will_not_send() {
|
||||
registries: ®istries,
|
||||
};
|
||||
let usage = |visibility, line: &str| {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
|
||||
let mut screen = ChatScreen::new(
|
||||
&m,
|
||||
&c,
|
||||
"",
|
||||
false,
|
||||
0,
|
||||
640,
|
||||
360,
|
||||
ChatAbilities::new(visibility),
|
||||
);
|
||||
screen.type_text(&m, &c, line);
|
||||
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_world::{
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
Arc, Mutex, OnceLock, PoisonError,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
@@ -108,6 +108,9 @@ pub enum BlockEntity {
|
||||
pub struct ModBlockEntity {
|
||||
fields: ModBlockEntityFields,
|
||||
revision: ModRevision,
|
||||
// A client update can consume transient effects without changing saved
|
||||
// fields. Most entities never use this; do not allocate their queue.
|
||||
client_update_fields: OnceLock<Mutex<Compound>>,
|
||||
}
|
||||
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
|
||||
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
|
||||
@@ -138,11 +141,55 @@ impl ModBlockEntity {
|
||||
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
|
||||
changes: 0,
|
||||
},
|
||||
client_update_fields: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
pub fn revision(&self) -> ModRevision {
|
||||
self.revision
|
||||
}
|
||||
/// Replace the owner's pending, one-shot client update fields. These
|
||||
/// fields are not saved, cloned, compared or retained for drawing, and
|
||||
/// changing them does not change this entity's revision. The owner must
|
||||
/// explicitly take and merge them into its update tag. Calling this does
|
||||
/// not schedule a block update.
|
||||
///
|
||||
/// An empty batch clears a pending batch without allocating an idle queue.
|
||||
pub fn set_client_update_fields(&mut self, fields: Compound) {
|
||||
if fields.is_empty() {
|
||||
self.clear_client_update_fields();
|
||||
return;
|
||||
}
|
||||
*self
|
||||
.client_update_fields
|
||||
.get_or_init(|| Mutex::new(Compound::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner) = fields;
|
||||
}
|
||||
/// Discard the owner's pending client update fields, without changing
|
||||
/// saved data, revision or a queue that has never been used.
|
||||
pub fn clear_client_update_fields(&mut self) {
|
||||
if let Some(fields) = self.client_update_fields.get() {
|
||||
fields
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clear();
|
||||
}
|
||||
}
|
||||
/// Atomically take the owner's pending fields at update-tag construction.
|
||||
/// A loaded chunk and a block change use this same boundary. Construct a
|
||||
/// tag once, then send that tag to every recipient; another construction
|
||||
/// sees an empty batch. A save or a clone does not consume this batch.
|
||||
///
|
||||
/// This is safe for an update-tag hook receiving only a shared reference,
|
||||
/// including a saved chunk encoded by a worker thread. If a previous lock
|
||||
/// holder panicked, preserve its pending data instead of dropping it.
|
||||
pub fn take_client_update_fields(&self) -> Compound {
|
||||
self.client_update_fields
|
||||
.get()
|
||||
.map_or_else(Compound::new, |fields| {
|
||||
std::mem::take(&mut *fields.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
})
|
||||
}
|
||||
/// A client's copy loads the tag the server sent (`loadWithComponents`
|
||||
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
|
||||
/// replace its own of the same names. One it already has as sent is
|
||||
@@ -706,6 +753,85 @@ pub struct CommandBlock {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transient_client_update_fields_are_lazy_replaceable_and_drained_once() {
|
||||
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
|
||||
id: "test:machine".into(),
|
||||
nbt: Compound::from([("progress".into(), Tag::Int(7))]),
|
||||
..Default::default()
|
||||
});
|
||||
let revision = machine.revision();
|
||||
let saved = machine.fields.clone();
|
||||
let debug = format!("{machine:?}");
|
||||
machine.clear_client_update_fields();
|
||||
machine.set_client_update_fields(Compound::new());
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
assert!(machine.client_update_fields.get().is_none());
|
||||
|
||||
machine.set_client_update_fields(Compound::from([("discarded".into(), Tag::Int(1))]));
|
||||
let expected = Compound::from([("effects".into(), Tag::List(vec![Tag::Int(2)]))]);
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
let copy = machine.clone();
|
||||
assert_eq!(machine, copy, "transient fields are not retained contents");
|
||||
assert_ne!(machine.revision(), copy.revision());
|
||||
assert!(copy.client_update_fields.get().is_none());
|
||||
assert!(copy.take_client_update_fields().is_empty());
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
machine.set_client_update_fields(expected);
|
||||
machine.clear_client_update_fields();
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
assert_eq!(machine.fields, saved);
|
||||
assert_eq!(machine.revision(), revision);
|
||||
assert_eq!(format!("{machine:?}"), debug);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_client_update_constructions_consume_one_batch() {
|
||||
let mut machine = ModBlockEntity::default();
|
||||
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
let received = std::thread::scope(|scope| {
|
||||
let handles = (0..16)
|
||||
.map(|_| scope.spawn(|| machine.take_client_update_fields()))
|
||||
.collect::<Vec<_>>();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|h| h.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let nonempty = received
|
||||
.into_iter()
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(nonempty, [expected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_client_update_lock_preserves_its_pending_batch() {
|
||||
let mut machine = ModBlockEntity::default();
|
||||
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
std::thread::scope(|scope| {
|
||||
assert!(
|
||||
scope
|
||||
.spawn(|| {
|
||||
let _held = machine.client_update_fields.get().unwrap().lock().unwrap();
|
||||
panic!("interrupt before consuming the queued batch");
|
||||
})
|
||||
.join()
|
||||
.is_err()
|
||||
);
|
||||
});
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
|
||||
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
|
||||
|
||||
@@ -243,11 +243,24 @@ impl SceneCollisions<'_, '_> {
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
) -> Result<Vec3> {
|
||||
self.resolve_in_border_with(bounds, movement, max_step, on_ground, border, &[])
|
||||
}
|
||||
/// [`Self::resolve_in_border`], also stopped by `entities`: the boxes
|
||||
/// `EntityGetter.getEntityCollisions` gave the moving entity.
|
||||
pub fn resolve_in_border_with(
|
||||
self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
entities: &[Aabb],
|
||||
) -> Result<Vec3> {
|
||||
let context = self.context(bounds);
|
||||
EntityCollisions {
|
||||
blocks: self,
|
||||
entities: &[],
|
||||
entities,
|
||||
border,
|
||||
}
|
||||
.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use horizon_telemetry::system::machine_arch;
|
||||
|
||||
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
@@ -242,9 +243,18 @@ fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// The build's architecture, and the machine's after "on" when it emulates the build.
|
||||
fn arch_label(build: &str, machine: &str) -> String {
|
||||
if build == machine {
|
||||
build.to_owned()
|
||||
} else {
|
||||
format!("{build} on {machine}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
let arch = arch_label(std::env::consts::ARCH, machine_arch());
|
||||
let version = if cfg!(target_os = "linux") {
|
||||
let name = read("/etc/os-release").and_then(|t| {
|
||||
t.lines()
|
||||
@@ -683,6 +693,17 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_emulated_build_names_both_architectures() {
|
||||
// A Windows ARM64 PC playing the x64 build: its reports say so (#7513 read "x86_64").
|
||||
assert_eq!(arch_label("x86_64", "aarch64"), "x86_64 on aarch64");
|
||||
assert_eq!(arch_label("aarch64", "aarch64"), "aarch64");
|
||||
assert_eq!(arch_label("x86_64", "x86_64"), "x86_64");
|
||||
// On the machine running the tests: native builds name one architecture.
|
||||
let label = arch_label(std::env::consts::ARCH, machine_arch());
|
||||
assert!(label.starts_with(std::env::consts::ARCH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
|
||||
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
|
||||
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
|
||||
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
|
||||
//! vanilla.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
|
||||
|
||||
/// A layer's texture, RGBA8.
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
}
|
||||
|
||||
/// One layer of a frame: which texture, with which blend, how bright and turned how.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Draw {
|
||||
pub image: u16,
|
||||
pub blend: Blend,
|
||||
pub brightness: f32,
|
||||
/// Camera-relative world from the skybox's space.
|
||||
pub rotation: Mat4,
|
||||
}
|
||||
|
||||
/// The eased brightness of each layer, kept by the game between frames.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
world: Option<i32>,
|
||||
eased: Vec<Transition>,
|
||||
}
|
||||
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
pub fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
|
||||
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
|
||||
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
pub fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &Conditions,
|
||||
sun_turns: f32,
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
out.clear();
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), Transition::default());
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
eased.value = target;
|
||||
target
|
||||
} else {
|
||||
eased.step(target, seconds, layer.transition)
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: layer.blend,
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::sky::Now;
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Now::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub struct EffectInstance {
|
||||
pub amplifier: i32,
|
||||
pub ambient: bool,
|
||||
pub visible: bool,
|
||||
/// `MobEffectInstance.showIcon`: whether the HUD and inventory list it.
|
||||
pub show_icon: bool,
|
||||
pub hidden: Option<Box<EffectInstance>>,
|
||||
}
|
||||
impl EffectInstance {
|
||||
@@ -24,9 +26,18 @@ impl EffectInstance {
|
||||
amplifier: amplifier.clamp(0, 255),
|
||||
ambient: false,
|
||||
visible: true,
|
||||
show_icon: true,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
/// `new MobEffectInstance(effect, duration, amplifier, ambient, visible)`:
|
||||
/// the icon shows exactly when the particles do.
|
||||
pub fn with_flags(mut self, ambient: bool, visible: bool) -> Self {
|
||||
self.ambient = ambient;
|
||||
self.visible = visible;
|
||||
self.show_icon = visible;
|
||||
self
|
||||
}
|
||||
fn infinite(&self) -> bool {
|
||||
self.duration == -1
|
||||
}
|
||||
@@ -71,6 +82,10 @@ impl EffectInstance {
|
||||
self.visible = take.visible;
|
||||
changed = true;
|
||||
}
|
||||
if take.show_icon != self.show_icon {
|
||||
self.show_icon = take.show_icon;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// `MobEffectInstance.tickClient`: a client counts its effects down
|
||||
@@ -98,6 +113,7 @@ impl EffectInstance {
|
||||
self.amplifier = h.amplifier;
|
||||
self.ambient = h.ambient;
|
||||
self.visible = h.visible;
|
||||
self.show_icon = h.show_icon;
|
||||
self.hidden = h.hidden;
|
||||
}
|
||||
}
|
||||
@@ -116,8 +132,9 @@ pub enum EffectAction {
|
||||
}
|
||||
/// `shouldApplyEffectTickThisTick` + `applyEffectTick` for the periodic effects.
|
||||
fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
|
||||
// Java's `>>` and `<<` take the shift distance modulo 32 (`wrapping_*`).
|
||||
let every = |base: i32| {
|
||||
let interval = base >> amp;
|
||||
let interval = base.wrapping_shr(amp as u32);
|
||||
interval <= 0 || tick % interval == 0
|
||||
};
|
||||
match id {
|
||||
@@ -126,10 +143,12 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
|
||||
"wither" if every(40) => Some(EffectAction::Hurt("minecraft:wither", 1.0)),
|
||||
"hunger" => Some(EffectAction::Exhaust(0.005 * (amp + 1) as f32)),
|
||||
"saturation" if tick >= 1 => Some(EffectAction::Eat(amp + 1, 1.0)),
|
||||
"instant_health" if tick >= 1 => Some(EffectAction::Heal((4 << amp.min(30)) as f32)),
|
||||
"instant_health" if tick >= 1 => Some(EffectAction::Heal(
|
||||
4i32.wrapping_shl(amp as u32).max(0) as f32,
|
||||
)),
|
||||
"instant_damage" if tick >= 1 => Some(EffectAction::Hurt(
|
||||
"minecraft:magic",
|
||||
(6 << amp.min(30)) as f32,
|
||||
6i32.wrapping_shl(amp as u32) as f32,
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
@@ -139,11 +158,11 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
|
||||
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
|
||||
match id {
|
||||
"instant_health" => Some(EffectAction::Heal(
|
||||
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
(scale * f64::from(4i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"instant_damage" => Some(EffectAction::Hurt(
|
||||
"minecraft:magic",
|
||||
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
(scale * f64::from(6i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
|
||||
_ => None,
|
||||
@@ -168,6 +187,57 @@ pub enum OmenTick {
|
||||
/// It added this effect, then returned false.
|
||||
Add(EffectInstance),
|
||||
}
|
||||
/// A saved player's effect: `MobEffectInstance.Details` under its `id`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SavedEffect {
|
||||
pub id: String,
|
||||
pub duration: i32,
|
||||
pub amplifier: i32,
|
||||
pub ambient: bool,
|
||||
/// `show_particles`.
|
||||
pub particles: bool,
|
||||
/// `show_icon`.
|
||||
pub icon: bool,
|
||||
/// `hidden_effect`: the weaker, longer instance of the same effect that
|
||||
/// comes back when this one ends.
|
||||
pub hidden: Option<Box<SavedEffect>>,
|
||||
}
|
||||
impl SavedEffect {
|
||||
/// Ambient off, particles and icon shown, nothing hidden: the codec's
|
||||
/// defaults.
|
||||
pub fn new(id: impl Into<String>, duration: i32, amplifier: i32) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
duration,
|
||||
amplifier,
|
||||
ambient: false,
|
||||
particles: true,
|
||||
icon: true,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
fn of(e: &EffectInstance) -> Self {
|
||||
Self {
|
||||
id: potions::namespaced(e.id).to_owned(),
|
||||
duration: e.duration,
|
||||
amplifier: e.amplifier,
|
||||
ambient: e.ambient,
|
||||
particles: e.visible,
|
||||
icon: e.show_icon,
|
||||
hidden: e.hidden.as_deref().map(|h| Box::new(Self::of(h))),
|
||||
}
|
||||
}
|
||||
/// `new MobEffectInstance(effect, details)`: the hidden chain comes back
|
||||
/// with it.
|
||||
fn instance(&self, id: &'static str) -> EffectInstance {
|
||||
let mut e = EffectInstance::new(id, self.duration, self.amplifier);
|
||||
e.ambient = self.ambient;
|
||||
e.visible = self.particles;
|
||||
e.show_icon = self.icon;
|
||||
e.hidden = self.hidden.as_deref().map(|h| Box::new(h.instance(id)));
|
||||
e
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PlayerEffects {
|
||||
pub list: Vec<EffectInstance>,
|
||||
@@ -200,39 +270,25 @@ impl PlayerEffects {
|
||||
pub fn clear(&mut self) {
|
||||
self.list.clear();
|
||||
}
|
||||
/// The effects of a saved player (`Player.active_effects`: id,
|
||||
/// duration, amplifier, ambient, particles visible).
|
||||
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
|
||||
/// The effects of a saved player (`Player.active_effects`), each with
|
||||
/// the instances it hides.
|
||||
pub fn from_saved(saved: &[SavedEffect]) -> Self {
|
||||
let mut effects = Self::default();
|
||||
for (id, duration, amplifier, ambient, particles) in saved {
|
||||
if let Some(def) = potions::effect(id) {
|
||||
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
|
||||
e.ambient = *ambient;
|
||||
e.visible = *particles;
|
||||
effects.add(e);
|
||||
for s in saved {
|
||||
if let Some(def) = potions::effect(&s.id) {
|
||||
effects.add(s.instance(def.id));
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
/// `Player.active_effects` for the save.
|
||||
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
|
||||
self.list
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
potions::namespaced(e.id).to_owned(),
|
||||
e.duration,
|
||||
e.amplifier,
|
||||
e.ambient,
|
||||
e.visible,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
pub fn saved(&self) -> Vec<SavedEffect> {
|
||||
self.list.iter().map(SavedEffect::of).collect()
|
||||
}
|
||||
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
|
||||
/// particles visible and the icon shown (always, for these effects).
|
||||
/// particles visible and the icon shown.
|
||||
pub fn flags(e: &EffectInstance) -> i8 {
|
||||
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
|
||||
i8::from(e.ambient) | i8::from(e.visible) << 1 | i8::from(e.show_icon) << 2
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
|
||||
@@ -494,7 +550,75 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `HealOrHarmMobEffect`: Java's `<<` shifts by the amplifier modulo
|
||||
/// 32 and wraps, so amplifier 32 is amplifier 0 again; a ticked heal is
|
||||
/// at least 0 (#11386).
|
||||
#[test]
|
||||
fn heal_and_harm_shift_like_java() {
|
||||
let magic = "minecraft:magic";
|
||||
assert_eq!(
|
||||
instant("instant_health", 32, 1.0),
|
||||
Some(EffectAction::Heal(4.0))
|
||||
);
|
||||
assert_eq!(
|
||||
instant("instant_damage", 33, 1.0),
|
||||
Some(EffectAction::Hurt(magic, 12.0))
|
||||
);
|
||||
assert_eq!(
|
||||
instant("instant_health", 1, 0.5),
|
||||
Some(EffectAction::Heal(4.0))
|
||||
);
|
||||
assert_eq!(
|
||||
periodic("instant_health", 1, 32),
|
||||
Some(EffectAction::Heal(4.0))
|
||||
);
|
||||
// 4 << 29 is i32::MIN: the heal stops at 0. 6 << 30 wraps to i32::MIN.
|
||||
assert_eq!(
|
||||
periodic("instant_health", 1, 29),
|
||||
Some(EffectAction::Heal(0.0))
|
||||
);
|
||||
assert_eq!(
|
||||
periodic("instant_damage", 1, 30),
|
||||
Some(EffectAction::Hurt(magic, i32::MIN as f32))
|
||||
);
|
||||
// Regeneration: 50 >> 32 is 50 again, not 0 (which would heal each tick).
|
||||
assert_eq!(periodic("regeneration", 49, 32), None);
|
||||
assert_eq!(
|
||||
periodic("regeneration", 50, 32),
|
||||
Some(EffectAction::Heal(1.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// Warden darkness (`new MobEffectInstance(DARKNESS, 260, 0, false,
|
||||
/// false)`) shows neither particles nor its icon; a renewal that shows the
|
||||
/// icon turns it back on (`MobEffectInstance.update`).
|
||||
#[test]
|
||||
fn an_effect_without_particles_hides_its_icon() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("darkness", 260, 0).with_flags(false, false));
|
||||
let darkness = fx.get("darkness").unwrap();
|
||||
// ClientboundUpdateMobEffectPacket: no ambient, particles or icon bit.
|
||||
assert_eq!(PlayerEffects::flags(darkness), 0);
|
||||
assert!(!crate::hud_overlays::EffectIcon::from(darkness).show_icon);
|
||||
assert!(fx.add(EffectInstance::new("darkness", 100, 0)));
|
||||
assert_eq!(PlayerEffects::flags(fx.get("darkness").unwrap()), 2 | 4);
|
||||
|
||||
// Details keeps show_icon apart from show_particles.
|
||||
let mut speed = EffectInstance::new("speed", 100, 0);
|
||||
speed.show_icon = false;
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(speed);
|
||||
let saved = fx.saved();
|
||||
assert_eq!(
|
||||
saved,
|
||||
[SavedEffect {
|
||||
icon: false,
|
||||
..SavedEffect::new("minecraft:speed", 100, 0)
|
||||
}]
|
||||
);
|
||||
let loaded = PlayerEffects::from_saved(&saved);
|
||||
assert_eq!(PlayerEffects::flags(loaded.get("speed").unwrap()), 2);
|
||||
}
|
||||
#[test]
|
||||
fn adding_counts_a_change_only_when_the_effect_changed() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
@@ -531,6 +655,21 @@ mod tests {
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
|
||||
}
|
||||
#[test]
|
||||
fn a_reloaded_effect_falls_back_to_the_one_it_hid() {
|
||||
// /effect give poison 6 s level II over poison 30 s level I, saved
|
||||
// and loaded: when level II runs out, level I resumes with what is
|
||||
// left of its 30 s.
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("poison", 600, 0));
|
||||
fx.add(EffectInstance::new("poison", 120, 1));
|
||||
let mut loaded = PlayerEffects::from_saved(&fx.saved());
|
||||
for _ in 0..120 {
|
||||
loaded.tick();
|
||||
}
|
||||
let poison = loaded.get("poison").expect("level I resumed");
|
||||
assert_eq!((poison.amplifier, poison.duration), (0, 480));
|
||||
}
|
||||
/// The only test here that registers mod effects (once per process).
|
||||
#[test]
|
||||
fn a_mod_effect_saves_under_its_namespace() {
|
||||
@@ -546,11 +685,11 @@ mod tests {
|
||||
1,
|
||||
));
|
||||
let saved = fx.saved();
|
||||
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
|
||||
assert_eq!(saved, [SavedEffect::new("ruby:glow", 600, 1)]);
|
||||
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
|
||||
// Without its mod the saved effect is dropped, as an unknown id is.
|
||||
assert!(
|
||||
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
|
||||
PlayerEffects::from_saved(&[SavedEffect::new("gone:glow", 600, 1)])
|
||||
.list
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Still-fluid tiling follows Catnip/Ponder 1.0.82 FluidRenderHelper.renderTiledFace.
|
||||
// Copyright (c) 2022 The Create Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
/// Controls a still-fluid box. The tint is stack ARGB, independent of biome tint.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeFluidRenderOptions {
|
||||
pub argb: Option<u32>,
|
||||
pub render_bottom: bool,
|
||||
/// TextureAtlasSprite.uvShrinkRatio: 4 / the largest stitched atlas dimension.
|
||||
/// Explicit because Horizon's block sprites occupy arrays rather than a stitched atlas.
|
||||
pub uv_shrink_ratio: f32,
|
||||
}
|
||||
|
||||
/// A corner before the caller's pose, with UVs within the still sprite, and its outward normal.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidVertex {
|
||||
pub pos: [f32; 3],
|
||||
pub uv: [f32; 2],
|
||||
pub normal: [i8; 3],
|
||||
}
|
||||
|
||||
/// Preserve sky light and increase block light to the fluid's luminosity (native light packing).
|
||||
pub fn fluid_light(light: u32, luminosity: u8) -> u32 {
|
||||
(light & 0xf0) | (((light >> 8 & 0xf0).max(u32::from(luminosity.min(15)) * 16)) << 8)
|
||||
}
|
||||
|
||||
/// Emit whole tiled quads, or refuse invalid/unreasonably large geometry before emitting anything.
|
||||
/// Negative coordinates and exact integer boundaries retain the source's local UV fractions.
|
||||
pub fn fluid_box(
|
||||
bounds: [f32; 6],
|
||||
options: NativeFluidRenderOptions,
|
||||
mut emit: impl FnMut(FluidVertex),
|
||||
) -> bool {
|
||||
if !options.uv_shrink_ratio.is_finite()
|
||||
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
|
||||
|| bounds
|
||||
.iter()
|
||||
.any(|x| !x.is_finite() || x.abs() > 1_048_576.0)
|
||||
|| (0..3).any(|i| bounds[i] > bounds[i + 3])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let tiles =
|
||||
std::array::from_fn::<_, 3, _>(|i| (bounds[i + 3].ceil() - bounds[i].floor()) as u64);
|
||||
let quads = tiles[0] * tiles[1] * 2
|
||||
+ tiles[1] * tiles[2] * 2
|
||||
+ tiles[0] * tiles[2] * if options.render_bottom { 2 } else { 1 };
|
||||
if quads > 65_536 {
|
||||
return false;
|
||||
}
|
||||
let [x0, y0, z0, x1, y1, z1] = bounds;
|
||||
for (side, face) in [
|
||||
(Direction::Down, [x0, z0, x1, z1, y0]),
|
||||
(Direction::Up, [x0, z0, x1, z1, y1]),
|
||||
(Direction::North, [x0, y0, x1, y1, z0]),
|
||||
(Direction::South, [x0, y0, x1, y1, z1]),
|
||||
(Direction::West, [z0, y0, z1, y1, x0]),
|
||||
(Direction::East, [z0, y0, z1, y1, x1]),
|
||||
] {
|
||||
if side != Direction::Down || options.render_bottom {
|
||||
tiled_face(&mut emit, side, face, options.uv_shrink_ratio);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn tiled_face(out: &mut impl FnMut(FluidVertex), side: Direction, face: [f32; 5], ratio: f32) {
|
||||
let [a0, b0, a1, b1, plane] = face;
|
||||
let positive = matches!(side, Direction::Up | Direction::South | Direction::East);
|
||||
let shrink = ratio * 0.25;
|
||||
let lerp = |v| v + shrink * (0.5 - v);
|
||||
let mut a = a0;
|
||||
while a < a1 {
|
||||
let end_a = (a.floor() + 1.0).min(a1);
|
||||
let (u0, u1) = if matches!(side, Direction::North | Direction::East) {
|
||||
(end_a.ceil() - end_a, end_a.ceil() - a)
|
||||
} else {
|
||||
(a - a.floor(), end_a - a.floor())
|
||||
};
|
||||
let (u0, u1) = (lerp(u0), lerp(u1));
|
||||
let mut b = b0;
|
||||
while b < b1 {
|
||||
let end_b = (b.floor() + 1.0).min(b1);
|
||||
let (v0, v1) = if side == Direction::Up {
|
||||
(b - b.floor(), end_b - b.floor())
|
||||
} else {
|
||||
(end_b.ceil() - end_b, end_b.ceil() - b)
|
||||
};
|
||||
let (v0, v1) = (lerp(v0), lerp(v1));
|
||||
let positions = match side {
|
||||
Direction::West | Direction::East => {
|
||||
let (near, far) = if positive { (end_a, a) } else { (a, end_a) };
|
||||
[
|
||||
[plane, end_b, near],
|
||||
[plane, b, near],
|
||||
[plane, b, far],
|
||||
[plane, end_b, far],
|
||||
]
|
||||
}
|
||||
Direction::North | Direction::South => {
|
||||
let (near, far) = if positive { (a, end_a) } else { (end_a, a) };
|
||||
[
|
||||
[near, end_b, plane],
|
||||
[near, b, plane],
|
||||
[far, b, plane],
|
||||
[far, end_b, plane],
|
||||
]
|
||||
}
|
||||
Direction::Down | Direction::Up => {
|
||||
let (near, far) = if positive { (b, end_b) } else { (end_b, b) };
|
||||
[
|
||||
[a, plane, near],
|
||||
[a, plane, far],
|
||||
[end_a, plane, far],
|
||||
[end_a, plane, near],
|
||||
]
|
||||
}
|
||||
};
|
||||
let normal = match side {
|
||||
Direction::Down => [0, -1, 0],
|
||||
Direction::Up => [0, 1, 0],
|
||||
Direction::North => [0, 0, -1],
|
||||
Direction::South => [0, 0, 1],
|
||||
Direction::West => [-1, 0, 0],
|
||||
Direction::East => [1, 0, 0],
|
||||
};
|
||||
for (pos, uv) in positions
|
||||
.into_iter()
|
||||
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
|
||||
{
|
||||
out(FluidVertex { pos, uv, normal });
|
||||
}
|
||||
b = end_b;
|
||||
}
|
||||
a = end_a;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn source_tiling_is_outward_and_does_not_inset_geometry() {
|
||||
let bounds = [-1.25, 0.125, -0.25, 1.5, 1.125, 0.75];
|
||||
let mut result = Vec::new();
|
||||
assert!(fluid_box(
|
||||
bounds,
|
||||
NativeFluidRenderOptions {
|
||||
argb: None,
|
||||
render_bottom: false,
|
||||
uv_shrink_ratio: 4.0 / 2048.0,
|
||||
},
|
||||
|v| result.push(v),
|
||||
));
|
||||
assert_eq!(result.len(), (4 * 2 * 2 + 2 * 2 * 2 + 4 * 2) * 4);
|
||||
for quad in result.as_chunks::<4>().0 {
|
||||
let [a, b, c, _] = quad.map(|v| glam::Vec3::from_array(v.pos));
|
||||
assert!(
|
||||
(b - a)
|
||||
.cross(c - a)
|
||||
.dot(glam::Vec3::from_array(quad[0].normal.map(f32::from)))
|
||||
> 0.0
|
||||
);
|
||||
assert!(
|
||||
quad.iter()
|
||||
.all(|v| (0..3).all(|i| v.pos[i] >= bounds[i] && v.pos[i] <= bounds[i + 3]))
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
result
|
||||
.iter()
|
||||
.any(|v| v.pos == [bounds[0], bounds[4], bounds[2]])
|
||||
);
|
||||
assert!(!fluid_box(
|
||||
[0.0, 0.0, 0.0, 1_000.0, 1_000.0, 1_000.0],
|
||||
NativeFluidRenderOptions {
|
||||
argb: None,
|
||||
render_bottom: true,
|
||||
uv_shrink_ratio: 0.0,
|
||||
},
|
||||
|_| panic!("invalid geometry must fail before emitting"),
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn luminosity_changes_only_block_light() {
|
||||
assert_eq!(fluid_light(0x20a0, 15), 0xf0a0);
|
||||
assert_eq!(fluid_light(0x25a5, 15), 0xf0a0);
|
||||
assert_eq!(fluid_light(0xe030, 3), 0xe030);
|
||||
}
|
||||
}
|
||||
@@ -413,6 +413,12 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
}
|
||||
/// What `AtmosphericFogEnvironment.updateRainFogState` eases the rain fog toward: the rain, where
|
||||
/// the sky reaches the eye, halved in a biome without precipitation.
|
||||
pub fn rain_fog_target(rain: f32, sky_light: u8, biome_rains: bool) -> f32 {
|
||||
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
|
||||
rain * sky * if biome_rains { 1.0 } else { 0.5 }
|
||||
}
|
||||
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
|
||||
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
|
||||
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
|
||||
@@ -428,10 +434,14 @@ pub fn update_rain_fog(
|
||||
biome_rains: bool,
|
||||
delta_ticks: f32,
|
||||
) {
|
||||
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
|
||||
let target = rain * sky * if biome_rains { 1.0 } else { 0.5 };
|
||||
let target = rain_fog_target(rain, sky_light, biome_rains);
|
||||
state.rain_fog_multiplier += (target - state.rain_fog_multiplier) * delta_ticks * 0.2;
|
||||
}
|
||||
/// The rain fog at its target at once: a frozen world under `HORIZON_STILL`, where the rain sits
|
||||
/// at its level and easing would wait for ticks that do not come.
|
||||
pub fn settle_rain_fog(state: &mut FogState, rain: f32, sky_light: u8, biome_rains: bool) {
|
||||
state.rain_fog_multiplier = rain_fog_target(rain, sky_light, biome_rains);
|
||||
}
|
||||
fn rgb(c: u32) -> [f32; 3] {
|
||||
[
|
||||
(c >> 16 & 255) as f32 / 255.0,
|
||||
@@ -726,6 +736,30 @@ mod tests {
|
||||
assert_eq!(clear.environmental_end, settled.environmental_end);
|
||||
assert_eq!(clear.color, settled.color);
|
||||
}
|
||||
/// Settling puts the rain fog at its target exactly, where a tick of easing moves it a fifth
|
||||
/// of the way, as before.
|
||||
#[test]
|
||||
fn settled_rain_fog_is_its_target_and_easing_is_unchanged() {
|
||||
assert_eq!(
|
||||
rain_fog_target(1.0, 15, false),
|
||||
0.5,
|
||||
"a dry biome halves it"
|
||||
);
|
||||
assert_eq!(rain_fog_target(1.0, 8, true), 0.0, "no sky, no rain fog");
|
||||
let start = FogState {
|
||||
rain_fog_multiplier: 0.1,
|
||||
..FogState::default()
|
||||
};
|
||||
let mut settled = start;
|
||||
settle_rain_fog(&mut settled, 0.5, 15, true);
|
||||
assert_eq!(settled.rain_fog_multiplier, 0.5);
|
||||
let mut eased = start;
|
||||
update_rain_fog(&mut eased, 0.5, 15, true, 1.0);
|
||||
assert_eq!(
|
||||
eased.rain_fog_multiplier.to_bits(),
|
||||
(0.1f32 + (0.5f32 - 0.1) * 1.0 * 0.2).to_bits()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn rain_fog_pulls_fog_in() {
|
||||
let sky = Sky {
|
||||
|
||||
@@ -45,6 +45,7 @@ fn static_entity_shader() -> String {
|
||||
mod crumbling_tests;
|
||||
mod entity_batch;
|
||||
mod entity_outline;
|
||||
mod fluid;
|
||||
#[cfg(test)]
|
||||
mod inter_stage_tests;
|
||||
#[cfg(test)]
|
||||
@@ -53,9 +54,9 @@ mod weather;
|
||||
mod world_text;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
|
||||
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, FLUID_OVERLAY, OIT_ENTITY,
|
||||
SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
@@ -170,6 +171,7 @@ mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
mod displace;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
@@ -1043,6 +1045,25 @@ pub struct ScreenshotRequest {
|
||||
/// `Renderer::screenshot_quality`, not the window's.
|
||||
pub scaled: bool,
|
||||
}
|
||||
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
|
||||
/// for the frames HDR is off, and with it where the display offers HDR output: the
|
||||
/// lighting's code, even never run, slows the shader on Apple GPUs (~15% of the solid
|
||||
/// pass at Retina sizes).
|
||||
pub struct SolidPipeline {
|
||||
plain: wgpu::RenderPipeline,
|
||||
hdr: Option<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl SolidPipeline {
|
||||
/// The pipeline for a frame with HDR output on (`hdr`) or off.
|
||||
pub fn get(&self, hdr: bool) -> &wgpu::RenderPipeline {
|
||||
match &self.hdr {
|
||||
Some(lit) if hdr => lit,
|
||||
_ => &self.plain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -1094,10 +1115,10 @@ pub struct Renderer {
|
||||
/// operation fails on a lost device (no error scope sees it).
|
||||
device_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
pub config: wgpu::SurfaceConfiguration,
|
||||
solid: wgpu::RenderPipeline,
|
||||
solid: SolidPipeline,
|
||||
/// `solid` split in two passes for the world pass (terrain.wgsl's `opaque_quad`).
|
||||
solid_opaque: wgpu::RenderPipeline,
|
||||
solid_cutout: wgpu::RenderPipeline,
|
||||
solid_opaque: SolidPipeline,
|
||||
solid_cutout: SolidPipeline,
|
||||
translucent: wgpu::RenderPipeline,
|
||||
translucent_mark: wgpu::RenderPipeline,
|
||||
/// Flat section draws (`flat`), where multi-draw is emulated.
|
||||
@@ -1194,10 +1215,14 @@ pub struct Renderer {
|
||||
world_primitives: world_primitives::WorldPrimitives,
|
||||
text_polygon_offset_pipeline: wgpu::RenderPipeline,
|
||||
world_text_pipeline: wgpu::RenderPipeline,
|
||||
fluid_pipeline: wgpu::RenderPipeline,
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
stars: stars::Stars,
|
||||
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
|
||||
custom_sky: custom_sky::CustomSky,
|
||||
custom_sky_draws: Vec<crate::custom_sky::Draw>,
|
||||
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
|
||||
entity_outline: entity_outline::EntityOutline,
|
||||
panorama: panorama::Panorama,
|
||||
@@ -1226,6 +1251,8 @@ pub struct Renderer {
|
||||
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
|
||||
entity_skin: Option<entity_skin::EntitySkin>,
|
||||
group: wgpu::BindGroup,
|
||||
fluid_group: wgpu::BindGroup,
|
||||
fluid_sampler: wgpu::Sampler,
|
||||
globals: wgpu::Buffer,
|
||||
/// Resource-pack normal/specular maps, split like the colour sprite arrays.
|
||||
pbr_maps: Option<Vec<[wgpu::Texture; 2]>>,
|
||||
@@ -1389,9 +1416,12 @@ pub struct Renderer {
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// What every pipeline that draws the frame targets: the swapchain's 8-bit format, or
|
||||
/// `Rgba16Unorm` when the renderer was made with HDR output on (`wide_frame`) and the
|
||||
/// device draws and blends into it. A 16-bit frame with HDR off since reaches the
|
||||
/// swapchain through `readback`.
|
||||
/// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame
|
||||
/// with HDR off since reaches the swapchain through `readback`.
|
||||
frame_format: wgpu::TextureFormat,
|
||||
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
|
||||
/// the frame that exists (`set_native_shader_pack`).
|
||||
frame_kept_logged: bool,
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
/// (`graphics::HDR_MARKER`).
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
@@ -1867,9 +1897,10 @@ impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
|
||||
}
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
/// which picks the backends itself. Auto tries DirectX 12 first on Windows ARM64,
|
||||
/// then Vulkan, based on the device-loss observed in a Windows ARM64 test. Other
|
||||
/// `os` is `std::env::consts::OS`; `arch` the machine's (`system::machine_arch`), not the
|
||||
/// build's; `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends itself.
|
||||
/// Auto tries DirectX 12 first on Windows ARM64, the x64 build emulated there included,
|
||||
/// then Vulkan, based on the device losses seen there (#7513). Other
|
||||
/// Windows systems try Vulkan, then DirectX 12, then OpenGL; Linux tries Vulkan then
|
||||
/// OpenGL. Each is tried when the one before has no surface, adapter or device. A native
|
||||
/// crash in the driver can't be
|
||||
@@ -1992,7 +2023,13 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
|
||||
features.contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) && format.allowed_usages.contains(
|
||||
) && draws_frame(format)
|
||||
}
|
||||
|
||||
/// Whether a format with the adapter's `format` features takes every usage and flag the
|
||||
/// passes take of the frame.
|
||||
fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
|
||||
format.allowed_usages.contains(
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
@@ -2002,17 +2039,28 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
|
||||
)
|
||||
}
|
||||
|
||||
/// What a renderer's frame is drawn in: `WIDE_FRAME` when it is made with HDR on and Smooth
|
||||
/// Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
|
||||
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
|
||||
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
|
||||
/// The frame of a renderer made under `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md)
|
||||
/// with a shader API 0.2 pack picked, or with HDR on and no pack: float, so values over 1
|
||||
/// survive from the world to the tonemap and the HDR pass.
|
||||
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
|
||||
/// it, for a 0.2 pack, or for HDR on without a pack where the surface offers HDR output
|
||||
/// (`linear`: `(a 0.2 pack picked, HDR on without a pack, the device draws it)`); else
|
||||
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
|
||||
/// `Graphics::wide_frame`), the surface offering HDR output (`hdr_offered`) and the device
|
||||
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
|
||||
/// shows them. Else the swapchain's 8-bit format, as without HDR.
|
||||
fn frame_format(
|
||||
wide_frame: bool,
|
||||
hdr_offered: bool,
|
||||
device_draws_it: bool,
|
||||
swapchain: wgpu::TextureFormat,
|
||||
linear: (bool, bool, bool),
|
||||
) -> wgpu::TextureFormat {
|
||||
if wide_frame && hdr_offered && device_draws_it {
|
||||
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
|
||||
LINEAR_FRAME
|
||||
} else if wide_frame && hdr_offered && device_draws_it {
|
||||
WIDE_FRAME
|
||||
} else {
|
||||
swapchain.remove_srgb_suffix()
|
||||
@@ -2033,7 +2081,8 @@ async fn open_device(
|
||||
for (order, allow_cpu) in device_search(
|
||||
api.with_env(),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
// The machine's: the x64 build emulated on a Windows ARM64 PC has its GPU too (#7513).
|
||||
horizon_telemetry::system::machine_arch(),
|
||||
wgpu_backend,
|
||||
) {
|
||||
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
|
||||
@@ -2485,6 +2534,8 @@ impl Renderer {
|
||||
translucent_batching: bool,
|
||||
wide_frame: bool,
|
||||
shader_pack: bool,
|
||||
linear_pack: bool,
|
||||
hdr_float: bool,
|
||||
) -> Result<Self> {
|
||||
let Opened {
|
||||
instance,
|
||||
@@ -2618,7 +2669,23 @@ impl Renderer {
|
||||
adapter.get_texture_format_features(WIDE_FRAME),
|
||||
);
|
||||
crate::graphics::offer_smooth_gradients(wide_ok);
|
||||
let frame_format = frame_format(wide_frame, hdr_output.is_some(), wide_ok, config.format);
|
||||
let linear = (
|
||||
linear_pack,
|
||||
hdr_float,
|
||||
draws_frame(adapter.get_texture_format_features(LINEAR_FRAME)),
|
||||
);
|
||||
if linear.0 && !linear.2 {
|
||||
log::warn!(
|
||||
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
|
||||
);
|
||||
}
|
||||
let frame_format = frame_format(
|
||||
wide_frame,
|
||||
hdr_output.is_some(),
|
||||
wide_ok,
|
||||
config.format,
|
||||
linear,
|
||||
);
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!("creating the pipelines"));
|
||||
}
|
||||
@@ -2717,6 +2784,13 @@ impl Renderer {
|
||||
bind_group_layouts: &[Some(&bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let fluid_pipeline = fluid::pipeline(
|
||||
&device,
|
||||
Some(&layout),
|
||||
&shader,
|
||||
config.format.remove_srgb_suffix(),
|
||||
&sprite_constants,
|
||||
);
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let terrain_buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
@@ -2738,16 +2812,24 @@ impl Renderer {
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &debug_line_attributes,
|
||||
})];
|
||||
let pipeline_in = |layout: &wgpu::PipelineLayout,
|
||||
label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
topology: wgpu::PrimitiveTopology,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
depth_test: bool,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
// The solid terrain's fragment constants without HDR output's lighting, for the
|
||||
// frames HDR is off (`SolidPipeline`).
|
||||
let plain_constants = [
|
||||
sprite_constants[0],
|
||||
sprite_constants[1],
|
||||
("HDR_LIGHTING", 0.0),
|
||||
];
|
||||
let pipeline_with = |constants: &[(&str, f64)],
|
||||
layout: &wgpu::PipelineLayout,
|
||||
label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
topology: wgpu::PrimitiveTopology,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
depth_test: bool,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
@@ -2778,7 +2860,7 @@ impl Renderer {
|
||||
module: &shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &sprite_constants,
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
@@ -2791,6 +2873,57 @@ impl Renderer {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipeline_in = |layout: &wgpu::PipelineLayout,
|
||||
label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
topology: wgpu::PrimitiveTopology,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
depth_test: bool,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
pipeline_with(
|
||||
&sprite_constants,
|
||||
layout,
|
||||
label,
|
||||
vertex,
|
||||
fragment,
|
||||
buffers,
|
||||
topology,
|
||||
cull,
|
||||
depth_write,
|
||||
depth_test,
|
||||
blend,
|
||||
)
|
||||
};
|
||||
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
|
||||
// output's lighting, and with it where the display offers HDR output.
|
||||
let solid_in = |layout: &wgpu::PipelineLayout,
|
||||
label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>]| {
|
||||
let build = |constants: &[(&str, f64)]| {
|
||||
pipeline_with(
|
||||
constants,
|
||||
layout,
|
||||
label,
|
||||
vertex,
|
||||
fragment,
|
||||
buffers,
|
||||
wgpu::PrimitiveTopology::TriangleList,
|
||||
Some(wgpu::Face::Back),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
};
|
||||
SolidPipeline {
|
||||
plain: build(&plain_constants),
|
||||
hdr: hdr_output.is_some().then(|| build(&sprite_constants)),
|
||||
}
|
||||
};
|
||||
let pipeline = |label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
@@ -2814,41 +2947,29 @@ impl Renderer {
|
||||
)
|
||||
};
|
||||
let tri = wgpu::PrimitiveTopology::TriangleList;
|
||||
let solid = pipeline(
|
||||
let solid = solid_in(
|
||||
&layout,
|
||||
"Terrain solid",
|
||||
"vs_terrain",
|
||||
"fs_solid",
|
||||
&terrain_buffers,
|
||||
tri,
|
||||
Some(wgpu::Face::Back),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
// `solid` in two passes (terrain.wgsl's `opaque_quad`, `mesher::Part`), for the
|
||||
// world pass without a shader pack: no cutout first, through a shader that never
|
||||
// discards, then the rest.
|
||||
let solid_opaque = pipeline(
|
||||
let solid_opaque = solid_in(
|
||||
&layout,
|
||||
"Terrain solid opaque",
|
||||
"vs_terrain_opaque",
|
||||
"fs_opaque",
|
||||
&terrain_buffers,
|
||||
tri,
|
||||
Some(wgpu::Face::Back),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
let solid_cutout = pipeline(
|
||||
let solid_cutout = solid_in(
|
||||
&layout,
|
||||
"Terrain solid cutout",
|
||||
"vs_terrain_cutout",
|
||||
"fs_solid",
|
||||
&terrain_buffers,
|
||||
tri,
|
||||
Some(wgpu::Face::Back),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
// The water mirror's camera sees the world from the other side of the plane, which
|
||||
// flips the winding: its solid terrain culls the front.
|
||||
@@ -3228,6 +3349,18 @@ impl Renderer {
|
||||
&lightmap_sampler,
|
||||
&emission_stats.view,
|
||||
);
|
||||
let fluid_sampler = device.create_sampler(&fluid::SAMPLER);
|
||||
let fluid_group = terrain_group(
|
||||
&device,
|
||||
"Still fluid resources",
|
||||
&bindings,
|
||||
&globals,
|
||||
&sprite_views,
|
||||
&fluid_sampler,
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
&emission_stats.view,
|
||||
);
|
||||
let crosshair_group = terrain_group(
|
||||
&device,
|
||||
"Debug crosshair resources",
|
||||
@@ -3795,12 +3928,8 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let world_text_pipeline = world_text::pipeline(
|
||||
&device,
|
||||
Some(&entity_layout),
|
||||
&entity_shader,
|
||||
config.format.remove_srgb_suffix(),
|
||||
);
|
||||
let world_text_pipeline =
|
||||
world_text::pipeline(&device, Some(&entity_layout), &entity_shader, frame_format);
|
||||
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
|
||||
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
|
||||
// ground it is stopped by, #5950). Terrain and entities are separate draw
|
||||
@@ -4369,6 +4498,7 @@ impl Renderer {
|
||||
layout, label, vertex, fragment, buffers, tri, cull, true, true, blend,
|
||||
)
|
||||
},
|
||||
|layout, label, vertex, fragment| solid_in(layout, label, vertex, fragment, &[]),
|
||||
)
|
||||
});
|
||||
// With flat draws' page groups the per-section solid draws read their vertices from
|
||||
@@ -4382,40 +4512,24 @@ impl Renderer {
|
||||
..Default::default()
|
||||
});
|
||||
let origins = [None, terrain_buffers[1].clone()];
|
||||
let pulled = |label: &str, vertex: &str, fragment: &str, cull| {
|
||||
let solid = |label: &str, vertex: &str, fragment: &str| {
|
||||
solid_in(&pulled_layout, label, vertex, fragment, &origins)
|
||||
};
|
||||
(
|
||||
solid("Terrain solid", "vs_section", "fs_solid"),
|
||||
solid("Terrain solid opaque", "vs_section_opaque", "fs_opaque"),
|
||||
solid("Terrain solid cutout", "vs_section_cutout", "fs_solid"),
|
||||
pipeline_in(
|
||||
&pulled_layout,
|
||||
label,
|
||||
vertex,
|
||||
fragment,
|
||||
&origins,
|
||||
tri,
|
||||
Some(cull),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let back = wgpu::Face::Back;
|
||||
(
|
||||
pulled("Terrain solid", "vs_section", "fs_solid", back),
|
||||
pulled(
|
||||
"Terrain solid opaque",
|
||||
"vs_section_opaque",
|
||||
"fs_opaque",
|
||||
back,
|
||||
),
|
||||
pulled(
|
||||
"Terrain solid cutout",
|
||||
"vs_section_cutout",
|
||||
"fs_solid",
|
||||
back,
|
||||
),
|
||||
pulled(
|
||||
"Terrain solid (mirrored)",
|
||||
"vs_section",
|
||||
"fs_solid_mirror",
|
||||
wgpu::Face::Front,
|
||||
&origins,
|
||||
tri,
|
||||
Some(wgpu::Face::Front),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -4517,6 +4631,7 @@ impl Renderer {
|
||||
let uploads = upload::Uploads::new(&device);
|
||||
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
|
||||
let stars = stars::Stars::new(&device, frame_format);
|
||||
let custom_sky = custom_sky::CustomSky::new(&device, frame_format);
|
||||
let entity_outline = entity_outline::EntityOutline::new(
|
||||
&device,
|
||||
(&entity_shader, &entity_layout, &entity_vertex),
|
||||
@@ -4635,10 +4750,13 @@ impl Renderer {
|
||||
world_primitives,
|
||||
text_polygon_offset_pipeline,
|
||||
world_text_pipeline,
|
||||
fluid_pipeline,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
end_sky: end_sky::EndSky::default(),
|
||||
stars,
|
||||
custom_sky,
|
||||
custom_sky_draws: Vec::new(),
|
||||
entity_outline,
|
||||
panorama,
|
||||
border_pipeline,
|
||||
@@ -4665,6 +4783,8 @@ impl Renderer {
|
||||
entity_batch: EntityBatch::default(),
|
||||
entity_skin,
|
||||
group,
|
||||
fluid_group,
|
||||
fluid_sampler,
|
||||
globals,
|
||||
pbr_maps,
|
||||
lightmap,
|
||||
@@ -4752,6 +4872,7 @@ impl Renderer {
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
frame_format,
|
||||
frame_kept_logged: false,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
pack_loading: None,
|
||||
@@ -4854,6 +4975,15 @@ impl Renderer {
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
|
||||
self.custom_sky.set_pack(&self.device, &self.queue, pack);
|
||||
}
|
||||
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
|
||||
/// frame, empty when the world's sky has none.
|
||||
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
|
||||
&mut self.custom_sky_draws
|
||||
}
|
||||
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
|
||||
/// outline, or the built-in one.
|
||||
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
|
||||
@@ -6536,6 +6666,22 @@ impl Renderer {
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
|
||||
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
|
||||
pub fn reset_shader_pack_history(&mut self) {
|
||||
// A frame still on the submit thread uses the targets and readbacks replaced
|
||||
// here.
|
||||
self.land_frame();
|
||||
if let Some((_, Some(post))) = &mut self.pack_post {
|
||||
post.reset_history(&self.device);
|
||||
}
|
||||
if let Some(native) = &mut self.native {
|
||||
native.frame.reset_history();
|
||||
if let Some(post) = &mut native.post {
|
||||
post.reset_history();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// As `set_shader_pack` with a pack, but its pipelines build on a thread of their own while
|
||||
/// the game keeps drawing without one: the driver compiles a big pack's programs for seconds.
|
||||
/// `poll_shader_pack` runs it once they are built. `label` and `started` come back with it.
|
||||
@@ -6699,6 +6845,18 @@ impl Renderer {
|
||||
block_light::LightTable,
|
||||
)>,
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
let linear = pack
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
|
||||
if (linear || self.frame_format == LINEAR_FRAME)
|
||||
&& linear != (self.frame_format == LINEAR_FRAME)
|
||||
&& !std::mem::replace(&mut self.frame_kept_logged, true)
|
||||
{
|
||||
log::info!(
|
||||
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader pack picked or dropped since runs on it",
|
||||
self.frame_format
|
||||
);
|
||||
}
|
||||
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
|
||||
self.native = None;
|
||||
return Ok(Vec::new());
|
||||
@@ -6944,6 +7102,17 @@ impl Renderer {
|
||||
&self.lightmap_sampler,
|
||||
&self.emission_stats.view,
|
||||
);
|
||||
self.fluid_group = terrain_group(
|
||||
&self.device,
|
||||
"Still fluid resources",
|
||||
&self.terrain_layout,
|
||||
&self.globals,
|
||||
&self.sprite_views,
|
||||
&self.fluid_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
&self.emission_stats.view,
|
||||
);
|
||||
}
|
||||
/// Block sprite layers per array on this device: a sprite's array is its layer over it, its
|
||||
/// layer in that array the rest (`block_sprites::Lookup`).
|
||||
@@ -8585,6 +8754,7 @@ impl Renderer {
|
||||
translucent_particle_quads,
|
||||
translucent_item_quads,
|
||||
oit_entity_quads,
|
||||
fluid_quads,
|
||||
world_text_quads,
|
||||
world_text_offset_quads,
|
||||
] = self.entity_batch.update(
|
||||
@@ -8613,13 +8783,17 @@ impl Renderer {
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads
|
||||
+ oit_entity_quads
|
||||
+ fluid_quads
|
||||
+ world_text_quads
|
||||
+ world_text_offset_quads;
|
||||
laps.lap("r_draw_lists");
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads =
|
||||
world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads;
|
||||
let base_quads = world_quads
|
||||
- oit_entity_quads
|
||||
- fluid_quads
|
||||
- world_text_quads
|
||||
- world_text_offset_quads;
|
||||
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
|
||||
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
|
||||
let (rebuilt, uniform) =
|
||||
@@ -8863,6 +9037,12 @@ impl Renderer {
|
||||
brightness,
|
||||
);
|
||||
}
|
||||
self.custom_sky.prepare(
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
jittered,
|
||||
&self.custom_sky_draws,
|
||||
);
|
||||
let celestial_vertices = frame.celestial.len().min(2048 * 4);
|
||||
if frame.end_sky && !frame.title {
|
||||
self.end_sky.prepare(&self.device, frame.end_sky_sprite);
|
||||
@@ -9471,6 +9651,8 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
if let Some(native) = &mut self.native {
|
||||
let reflection =
|
||||
reflection.map(|view| native.frame.linear_mirror(&self.device, &mut encoder, view));
|
||||
native
|
||||
.frame
|
||||
.set_reflection(&self.device, reflection.as_ref());
|
||||
@@ -9504,7 +9686,9 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title)
|
||||
.and_then(|n| Some((n.material.as_ref()?, &n.frame.group)));
|
||||
let solid_pipeline = material.map_or(&self.solid, |(m, _)| &m.solid);
|
||||
// Without HDR output, the solid shaders without its lighting (`SolidPipeline`).
|
||||
let hdr = self.hdr.is_some();
|
||||
let solid_pipeline = material.map_or(self.solid.get(hdr), |(m, _)| &m.solid);
|
||||
// Two solid passes only where a discard costs the GPU its hidden surface
|
||||
// removal: Apple's tiled GPUs, behind Metal. Elsewhere the second pass
|
||||
// would double the solid draw calls and vertex work for nothing.
|
||||
@@ -9602,6 +9786,12 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
|
||||
pass.draw(0..sunrise_fan::VERTICES as u32, 0..1);
|
||||
}
|
||||
// OptiFine custom sky layers, over the disc and sunrise and under the
|
||||
// sun, moon and stars.
|
||||
if self.custom_sky.is_drawing() {
|
||||
self.custom_sky.draw(&mut pass);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9614,6 +9804,13 @@ impl Renderer {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if frame.end_sky {
|
||||
self.end_sky.draw(&mut pass);
|
||||
// The End's custom sky layers go over its sky box.
|
||||
if self.custom_sky.is_drawing() && !pack_draws_sky && self.native.is_none()
|
||||
{
|
||||
self.custom_sky.draw(&mut pass);
|
||||
pass.set_pipeline(&self.celestial_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
}
|
||||
}
|
||||
if celestial_vertices > 0 && !pack_draws_celestial {
|
||||
// The Overworld's bodies start with the sun's quad
|
||||
@@ -9729,14 +9926,14 @@ impl Renderer {
|
||||
let passes: &[(&wgpu::RenderPipeline, Calls, Calls)] = match pack_flat {
|
||||
Some(p) => &[(&p.0, 0..flat.phase1_calls, 0..phase2_calls)],
|
||||
None if split_solid => &[
|
||||
(&flat.solid_opaque, 0..opaque, 0..phase2_split),
|
||||
(flat.solid_opaque.get(hdr), 0..opaque, 0..phase2_split),
|
||||
(
|
||||
&flat.solid_cutout,
|
||||
flat.solid_cutout.get(hdr),
|
||||
cutout..flat.phase1_calls,
|
||||
phase2_split..phase2_calls,
|
||||
),
|
||||
],
|
||||
None => &[(&flat.solid, 0..flat.phase1_calls, 0..phase2_calls)],
|
||||
None => &[(flat.solid.get(hdr), 0..flat.phase1_calls, 0..phase2_calls)],
|
||||
};
|
||||
let draw = |pass: &mut wgpu::RenderPass<'_>| {
|
||||
for (pipeline, calls, _) in passes {
|
||||
@@ -9789,9 +9986,13 @@ impl Renderer {
|
||||
type Draws = std::ops::Range<u32>;
|
||||
let passes: &[(&wgpu::RenderPipeline, Draws, Draws)] = match material {
|
||||
None if split_solid => &[
|
||||
(&self.solid_opaque, 0..opaque, phase1_draws..phase2_cutout),
|
||||
(
|
||||
&self.solid_cutout,
|
||||
self.solid_opaque.get(hdr),
|
||||
0..opaque,
|
||||
phase1_draws..phase2_cutout,
|
||||
),
|
||||
(
|
||||
self.solid_cutout.get(hdr),
|
||||
cutout..phase1_draws,
|
||||
phase2_cutout..solid_draws,
|
||||
),
|
||||
@@ -10024,6 +10225,20 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// Still-fluid block entities draw as their own ordinary sorted
|
||||
// translucent material, before terrain transparency. This material
|
||||
// always writes depth and never joins the improved-transparency queues.
|
||||
if fluid_quads > 0 {
|
||||
pass.set_pipeline(&self.fluid_pipeline);
|
||||
pass.set_bind_group(0, &self.fluid_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..fluid_quads * 6,
|
||||
((base_quads + oit_entity_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if crumbling_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -10133,12 +10348,7 @@ impl Renderer {
|
||||
depth.size(),
|
||||
);
|
||||
if let Some((color, _)) = &scaled {
|
||||
let color = color.texture();
|
||||
encoder.copy_texture_to_texture(
|
||||
color.as_image_copy(),
|
||||
native.frame.opaque_color.as_image_copy(),
|
||||
color.size(),
|
||||
);
|
||||
native.frame.copy_opaque(&self.device, &mut encoder, color);
|
||||
}
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
@@ -10415,7 +10625,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
let first = base_quads + oit_entity_quads;
|
||||
let first = base_quads + oit_entity_quads + fluid_quads;
|
||||
for (pipeline, count, offset) in [
|
||||
(&self.world_text_pipeline, world_text_quads, first),
|
||||
(
|
||||
@@ -10770,12 +10980,18 @@ impl Renderer {
|
||||
{
|
||||
self.readback.present(&self.device, &mut encoder, image);
|
||||
}
|
||||
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
|
||||
let lifts_world = !frame.title
|
||||
&& self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
|
||||
match (&self.hdr, &surface_frame) {
|
||||
(Some(hdr), Some(f)) => hdr.present(
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
&f.texture,
|
||||
!frame.title,
|
||||
lifts_world,
|
||||
self.own_timing.as_mut(),
|
||||
),
|
||||
// Offscreen nothing shows HDR: the target gets the frame as SDR shows it, for
|
||||
@@ -10794,7 +11010,7 @@ impl Renderer {
|
||||
.filter(|(request, _)| request.size.is_none())
|
||||
.zip(self.hdr.as_ref())
|
||||
.and_then(|(_, hdr)| {
|
||||
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
|
||||
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, lifts_world)?;
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some(copy_frame(&self.device, &mut encoder, &texture, size))
|
||||
});
|
||||
@@ -12324,20 +12540,75 @@ mod tests {
|
||||
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
|
||||
use super::frame_format;
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
|
||||
assert_eq!(frame_format(true, true, true, Bgra8Unorm), Rgba16Unorm);
|
||||
for (wide_frame, hdr_offered, draws) in [
|
||||
(false, true, true),
|
||||
(true, false, true),
|
||||
(true, true, false),
|
||||
// No float frame asked for (or a device without it) leaves it as before.
|
||||
for linear in [
|
||||
(false, false, false),
|
||||
(false, false, true),
|
||||
(true, false, false),
|
||||
(false, true, false),
|
||||
(true, true, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb),
|
||||
Bgra8Unorm
|
||||
frame_format(true, true, true, Bgra8Unorm, linear),
|
||||
Rgba16Unorm
|
||||
);
|
||||
for (wide_frame, hdr_offered, draws) in [
|
||||
(false, true, true),
|
||||
(true, false, true),
|
||||
(true, true, false),
|
||||
(false, false, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, linear),
|
||||
Bgra8Unorm
|
||||
);
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm, linear),
|
||||
Rgba8Unorm
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A shader API 0.2 pack picked under `HORIZON_HDR_FRAME=float` gets the float frame
|
||||
/// wherever the device draws it, HDR on or off.
|
||||
#[test]
|
||||
fn a_0_2_pack_gets_the_float_frame_where_the_device_draws_it() {
|
||||
use super::{LINEAR_FRAME, frame_format};
|
||||
use wgpu::TextureFormat::Bgra8UnormSrgb;
|
||||
for (wide_frame, hdr_offered, draws) in [(true, true, true), (false, false, false)] {
|
||||
assert_eq!(
|
||||
frame_format(
|
||||
wide_frame,
|
||||
hdr_offered,
|
||||
draws,
|
||||
Bgra8UnormSrgb,
|
||||
(true, false, true)
|
||||
),
|
||||
LINEAR_FRAME
|
||||
);
|
||||
}
|
||||
}
|
||||
/// HDR on without a pack under `HORIZON_HDR_FRAME=float` gets the float frame where the
|
||||
/// surface offers HDR output, Smooth Gradients or not; where it doesn't, the frame is as
|
||||
/// without the switch.
|
||||
#[test]
|
||||
fn hdr_without_a_pack_gets_the_float_frame_where_hdr_output_is_offered() {
|
||||
use super::{LINEAR_FRAME, frame_format};
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
|
||||
for (wide_frame, draws) in [(true, true), (false, false)] {
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, true, draws, Bgra8UnormSrgb, (false, true, true)),
|
||||
LINEAR_FRAME
|
||||
);
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm),
|
||||
Rgba8Unorm
|
||||
frame_format(
|
||||
wide_frame,
|
||||
false,
|
||||
draws,
|
||||
Bgra8UnormSrgb,
|
||||
(false, true, true)
|
||||
),
|
||||
Bgra8Unorm
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12414,7 +12685,7 @@ mod tests {
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -12737,6 +13008,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn plain_solid_shaders_translate_for_every_backend() {
|
||||
// `SolidPipeline::plain` builds terrain.wgsl's solid entries with `HDR_LIGHTING`
|
||||
// off on every backend: they must still translate (the other test translates them
|
||||
// with the constants' defaults).
|
||||
let module = naga::front::wgsl::parse_str(include_str!("terrain.wgsl")).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
let stage = naga::ShaderStage::Fragment;
|
||||
let constants = [("HDR_LIGHTING".to_owned(), 0.0)].into_iter().collect();
|
||||
for entry in ["fs_solid", "fs_opaque"] {
|
||||
let (module, info) = naga::back::pipeline_constants::process_overrides(
|
||||
&module,
|
||||
&info,
|
||||
Some((stage, entry)),
|
||||
&constants,
|
||||
)
|
||||
.unwrap();
|
||||
let msl = naga::back::msl::write_string(
|
||||
&module,
|
||||
&info,
|
||||
&naga::back::msl::Options {
|
||||
fake_missing_bindings: true,
|
||||
..Default::default()
|
||||
},
|
||||
&naga::back::msl::PipelineOptions {
|
||||
entry_point: Some((stage, entry.to_owned())),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
// Metal compiles the lighting out from this constant.
|
||||
assert!(
|
||||
msl.unwrap()
|
||||
.0
|
||||
.contains("constant bool HDR_LIGHTING = false;"),
|
||||
"{entry}"
|
||||
);
|
||||
let mut hlsl = String::new();
|
||||
naga::back::hlsl::Writer::new(
|
||||
&mut hlsl,
|
||||
&Default::default(),
|
||||
&naga::back::hlsl::PipelineOptions {
|
||||
entry_point: Some((stage, entry.to_owned())),
|
||||
},
|
||||
)
|
||||
.write(&module, &info, None)
|
||||
.unwrap_or_else(|e| panic!("{entry}: {e}"));
|
||||
let mut glsl = String::new();
|
||||
naga::back::glsl::Writer::new(
|
||||
&mut glsl,
|
||||
&module,
|
||||
&info,
|
||||
&naga::back::glsl::Options {
|
||||
version: naga::back::glsl::Version::Desktop(330),
|
||||
..Default::default()
|
||||
},
|
||||
&naga::back::glsl::PipelineOptions {
|
||||
shader_stage: stage,
|
||||
entry_point: entry.to_owned(),
|
||||
multiview: None,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.and_then(|mut w| w.write())
|
||||
.unwrap_or_else(|e| panic!("{entry}: {e}"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn entity_shader_validates_with_its_overlay_constants() {
|
||||
let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
|
||||
@@ -1040,7 +1040,14 @@ mod tests {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
|
||||
let pipeline = &pipeline;
|
||||
let solid = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str| {
|
||||
super::super::SolidPipeline {
|
||||
plain: pipeline(layout, label, "", "", None, None, true),
|
||||
hdr: None,
|
||||
}
|
||||
};
|
||||
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
|
||||
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
|
||||
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
|
||||
translucent.page_limit = 4 * GRAIN;
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
|
||||
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
|
||||
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
|
||||
use glam::Mat4;
|
||||
use horizon_optifine::sky::{Blend, Face};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::custom_sky::{CustomSky as Pack, Draw};
|
||||
|
||||
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
|
||||
const MODES: [Blend; 9] = [
|
||||
Blend::Add,
|
||||
Blend::Subtract,
|
||||
Blend::Multiply,
|
||||
Blend::Dodge,
|
||||
Blend::Burn,
|
||||
Blend::Screen,
|
||||
Blend::Replace,
|
||||
Blend::Overlay,
|
||||
Blend::Alpha,
|
||||
];
|
||||
|
||||
/// Layers drawn in a frame at most; a pack's later ones wait for a frame with fewer.
|
||||
const MAX_DRAWS: usize = 32;
|
||||
/// Dynamic uniform offsets are 256-byte aligned on every backend.
|
||||
const STRIDE: u64 = 256;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniform {
|
||||
clip_from_sky: [[f32; 4]; 4],
|
||||
brightness: f32,
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Vertex {
|
||||
position: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
cell: [f32; 4],
|
||||
}
|
||||
|
||||
pub(super) struct CustomSky {
|
||||
pipelines: Vec<wgpu::RenderPipeline>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
uniform: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
/// One bind group per layer texture of the pack.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
/// This frame's layers: bind group and pipeline indices.
|
||||
frame: Vec<(u16, u8)>,
|
||||
}
|
||||
|
||||
/// The fragment's output for each mode is in custom_sky.wgsl; these factors finish it.
|
||||
fn blend(mode: Blend) -> wgpu::BlendComponent {
|
||||
use wgpu::BlendFactor as F;
|
||||
let (src_factor, dst_factor, operation) = match mode {
|
||||
Blend::Add => (F::One, F::One, wgpu::BlendOperation::Add),
|
||||
Blend::Subtract => (F::One, F::One, wgpu::BlendOperation::ReverseSubtract),
|
||||
Blend::Multiply => (F::Zero, F::Src, wgpu::BlendOperation::Add),
|
||||
// dst + dst * src: lightens.
|
||||
Blend::Dodge => (F::Dst, F::One, wgpu::BlendOperation::Add),
|
||||
// dst * (1 - src): darkens.
|
||||
Blend::Burn => (F::Zero, F::OneMinusSrc, wgpu::BlendOperation::Add),
|
||||
// src + dst * (1 - src) = 1 - (1 - src)(1 - dst).
|
||||
Blend::Screen => (F::One, F::OneMinusSrc, wgpu::BlendOperation::Add),
|
||||
Blend::Replace => (F::One, F::Zero, wgpu::BlendOperation::Add),
|
||||
// dst * src + src * dst.
|
||||
Blend::Overlay => (F::Dst, F::Src, wgpu::BlendOperation::Add),
|
||||
Blend::Alpha => (F::SrcAlpha, F::OneMinusSrcAlpha, wgpu::BlendOperation::Add),
|
||||
};
|
||||
wgpu::BlendComponent {
|
||||
src_factor,
|
||||
dst_factor,
|
||||
operation,
|
||||
}
|
||||
}
|
||||
|
||||
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
|
||||
fn mesh() -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(36);
|
||||
for face in Face::ALL {
|
||||
let cell = face.cell();
|
||||
let [u0, v0, u1, v1] = cell;
|
||||
let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
|
||||
let corners = face.corners();
|
||||
for i in [0, 1, 2, 0, 2, 3] {
|
||||
out.push(Vertex {
|
||||
position: corners[i],
|
||||
uv: uv[i],
|
||||
cell,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl CustomSky {
|
||||
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Custom sky bindings"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: true,
|
||||
min_binding_size: wgpu::BufferSize::new(
|
||||
std::mem::size_of::<Uniform>() as u64
|
||||
),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Custom sky shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Custom sky layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
|
||||
let pipelines = MODES
|
||||
.iter()
|
||||
.map(|&mode| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_custom_sky"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_custom_sky"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
|
||||
// under the layers keeps its own.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: blend(mode),
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Custom sky"),
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Custom sky layers"),
|
||||
size: STRIDE * MAX_DRAWS as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Custom sky box"),
|
||||
contents: bytemuck::cast_slice(&mesh()),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
Self {
|
||||
pipelines,
|
||||
layout,
|
||||
sampler,
|
||||
uniform,
|
||||
vertices,
|
||||
groups: Vec::new(),
|
||||
frame: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pack's layer textures, replacing any before.
|
||||
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
|
||||
let limit = device.limits().max_texture_dimension_2d;
|
||||
self.groups = pack
|
||||
.images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
|
||||
let (width, height, rgba) = if image.width <= limit && image.height <= limit {
|
||||
(image.width, image.height, &image.rgba[..])
|
||||
} else {
|
||||
log::warn!(
|
||||
"custom sky texture of {}x{} too large",
|
||||
image.width,
|
||||
image.height
|
||||
);
|
||||
(1, 1, &[0u8; 4][..])
|
||||
};
|
||||
let texture = device.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
rgba,
|
||||
);
|
||||
let view = texture.create_view(&Default::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.uniform,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(std::mem::size_of::<Uniform>() as u64),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Upload this frame's layers; `clip` is clip space from camera-relative world.
|
||||
pub(super) fn prepare(
|
||||
&mut self,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
clip: Mat4,
|
||||
draws: &[Draw],
|
||||
) {
|
||||
self.frame.clear();
|
||||
for draw in draws.iter().take(MAX_DRAWS) {
|
||||
if usize::from(draw.image) >= self.groups.len() {
|
||||
continue;
|
||||
}
|
||||
let mode = MODES.iter().position(|m| *m == draw.blend).unwrap_or(0);
|
||||
let uniform = Uniform {
|
||||
clip_from_sky: (clip * draw.rotation).to_cols_array_2d(),
|
||||
brightness: draw.brightness.clamp(0.0, 1.0),
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
|
||||
self.frame.push((draw.image, mode as u8));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_drawing(&self) -> bool {
|
||||
!self.frame.is_empty()
|
||||
}
|
||||
|
||||
/// The layers [`CustomSky::prepare`] uploaded. Leaves the pass's pipeline and bind group 0
|
||||
/// changed.
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
for (i, &(image, mode)) in self.frame.iter().enumerate() {
|
||||
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
|
||||
pass.set_bind_group(
|
||||
0,
|
||||
&self.groups[usize::from(image)],
|
||||
&[(STRIDE * i as u64) as u32],
|
||||
);
|
||||
pass.draw(0..36, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
|
||||
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
|
||||
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
|
||||
let k = a * b;
|
||||
let src = match mode {
|
||||
Blend::Multiply => 1.0 + (t - 1.0) * k,
|
||||
Blend::Overlay => 0.5 + (t - 0.5) * k,
|
||||
Blend::Replace | Blend::Alpha => t,
|
||||
_ => t * k,
|
||||
};
|
||||
let c = blend(mode);
|
||||
let f = |factor: wgpu::BlendFactor| match factor {
|
||||
wgpu::BlendFactor::One => 1.0,
|
||||
wgpu::BlendFactor::Zero => 0.0,
|
||||
wgpu::BlendFactor::Src => src,
|
||||
wgpu::BlendFactor::OneMinusSrc => 1.0 - src,
|
||||
wgpu::BlendFactor::Dst => dst,
|
||||
wgpu::BlendFactor::SrcAlpha => k,
|
||||
wgpu::BlendFactor::OneMinusSrcAlpha => 1.0 - k,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let (s, d) = (src * f(c.src_factor), dst * f(c.dst_factor));
|
||||
match c.operation {
|
||||
wgpu::BlendOperation::Add => s + d,
|
||||
wgpu::BlendOperation::ReverseSubtract => d - s,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_gives_its_documented_result() {
|
||||
let close = |a: f32, b: f32| assert!((a - b).abs() < 1e-6, "{a} != {b}");
|
||||
// Full brightness and alpha, a sky texel of 0.4 under a layer texel of 0.8.
|
||||
let (d, t) = (0.4, 0.8);
|
||||
close(mix(Blend::Add, d, t, 1.0, 1.0), 1.2);
|
||||
close(mix(Blend::Subtract, d, t, 1.0, 1.0), -0.4);
|
||||
close(mix(Blend::Multiply, d, t, 1.0, 1.0), 0.32);
|
||||
close(mix(Blend::Screen, d, t, 1.0, 1.0), 1.0 - 0.6 * 0.2);
|
||||
close(mix(Blend::Replace, d, t, 1.0, 1.0), 0.8);
|
||||
close(mix(Blend::Overlay, d, t, 1.0, 1.0), 0.64);
|
||||
close(mix(Blend::Overlay, d, 0.2, 1.0, 1.0), 0.16);
|
||||
close(mix(Blend::Alpha, d, t, 0.25, 1.0), 0.4 * 0.75 + 0.8 * 0.25);
|
||||
assert!(mix(Blend::Dodge, d, t, 1.0, 1.0) > d);
|
||||
assert!(mix(Blend::Burn, d, t, 1.0, 1.0) < d);
|
||||
// Faded out, every mode but replace (drawn only when bright) leaves the sky alone.
|
||||
for mode in MODES {
|
||||
if mode != Blend::Replace {
|
||||
close(mix(mode, d, t, 1.0, 0.0), d);
|
||||
}
|
||||
}
|
||||
// Half faded: add adds half.
|
||||
close(mix(Blend::Add, d, t, 1.0, 0.5), 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_box_covers_every_face_with_its_cell() {
|
||||
let mesh = mesh();
|
||||
assert_eq!(mesh.len(), 36);
|
||||
for v in &mesh {
|
||||
let [u0, v0, u1, v1] = v.cell;
|
||||
assert!((u0..=u1).contains(&v.uv[0]) && (v0..=v1).contains(&v.uv[1]));
|
||||
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// OptiFine custom sky layers (gfx/custom_sky.rs): one skybox per draw, its six faces cut from a
|
||||
// 3x2 texture, mixed with the sky under it by the pipeline's blend. The fragment's output is
|
||||
// chosen so that blend gives the layer's documented mode at its brightness, and a layer at
|
||||
// brightness 0 leaves the sky as it was.
|
||||
struct Layer {
|
||||
// Projection * view (camera-relative) * the layer's rotation.
|
||||
clip_from_sky: mat4x4<f32>,
|
||||
brightness: f32,
|
||||
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
|
||||
@group(0) @binding(2) var sky_sampler: sampler;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
// The face's cell (u0, v0, u1, v1): sampling stays half a texel inside it.
|
||||
@location(1) @interpolate(flat) cell: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex fn vs_custom_sky(
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@location(2) cell: vec4<f32>,
|
||||
) -> Out {
|
||||
var out: Out;
|
||||
out.clip = layer.clip_from_sky * vec4(position * 100.0, 1.0);
|
||||
out.uv = uv;
|
||||
out.cell = cell;
|
||||
return out;
|
||||
}
|
||||
|
||||
const ADD: u32 = 0u;
|
||||
const SUBTRACT: u32 = 1u;
|
||||
const MULTIPLY: u32 = 2u;
|
||||
const DODGE: u32 = 3u;
|
||||
const BURN: u32 = 4u;
|
||||
const SCREEN: u32 = 5u;
|
||||
const REPLACE: u32 = 6u;
|
||||
const OVERLAY: u32 = 7u;
|
||||
const ALPHA: u32 = 8u;
|
||||
|
||||
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
|
||||
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
|
||||
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
|
||||
let t = textureSample(sky_texture, sky_sampler, uv);
|
||||
let k = t.a * layer.brightness;
|
||||
switch layer.mode {
|
||||
// dst * src: white leaves the sky alone.
|
||||
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
|
||||
// 2 * dst * src: grey leaves it alone.
|
||||
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
|
||||
case REPLACE: { return vec4(t.rgb, 1.0); }
|
||||
// mix(dst, src, alpha).
|
||||
case ALPHA: { return vec4(t.rgb, k); }
|
||||
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
|
||||
default: { return vec4(t.rgb * k, 1.0); }
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,11 @@ use super::Vertex;
|
||||
/// material at the end: the render types vanilla gives an `OitPipelineSet`
|
||||
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
|
||||
/// ignored and they stay in the first one, in their order.
|
||||
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
|
||||
const MATERIALS: usize = 12;
|
||||
/// Sorted still-fluid geometry follows, then the two native world-canvas text materials,
|
||||
/// NORMAL and POLYGON_OFFSET. Material storage order is independent of their draw stages.
|
||||
const MATERIALS: usize = 13;
|
||||
/// Sorted depth-writing still fluid, sampling terrain sprite arrays rather than the shared atlas.
|
||||
pub const FLUID_OVERLAY: u32 = 41;
|
||||
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
|
||||
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
@@ -60,12 +63,15 @@ pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
|
||||
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
|
||||
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
|
||||
fn material(layer: u32, oit: bool) -> usize {
|
||||
if layer & 0xffff == FLUID_OVERLAY {
|
||||
return 10;
|
||||
}
|
||||
if oit && layer & OIT_ENTITY != 0 {
|
||||
return 9;
|
||||
}
|
||||
match layer & 0xffff {
|
||||
WORLD_TEXT_OVERLAY => 10,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
|
||||
WORLD_TEXT_OVERLAY => 11,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 12,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
@@ -148,9 +154,9 @@ impl EntityBatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
// RenderType.text sorts alpha-blended quads from far to near. Keep
|
||||
// both world-canvas layers after existing materials (including eyes
|
||||
// moved out of additive); front text uses polygon offset.
|
||||
// Fluid and RenderType.text sort their own alpha-blended quads far to
|
||||
// near. Fluid precedes both world-canvas layers, after existing materials
|
||||
// (including eyes moved out of additive).
|
||||
for runs in &self.runs[10..] {
|
||||
let start = self.combined.len();
|
||||
for &(first, count) in runs {
|
||||
@@ -189,7 +195,7 @@ mod tests {
|
||||
use super::*;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
#[test]
|
||||
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
|
||||
fn fluid_and_world_canvas_sort_after_oit_eyes_before_frame_tails() {
|
||||
let quad = |layer, x| {
|
||||
[Vertex {
|
||||
layer,
|
||||
@@ -204,6 +210,8 @@ mod tests {
|
||||
(WORLD_TEXT_OVERLAY, 4.0),
|
||||
(WORLD_TEXT_OVERLAY, 5.0),
|
||||
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
|
||||
(FLUID_OVERLAY, 11.0),
|
||||
(FLUID_OVERLAY | OIT_ENTITY, 12.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
@@ -215,12 +223,14 @@ mod tests {
|
||||
let mut batch = EntityBatch::default();
|
||||
assert_eq!(
|
||||
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
|
||||
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
|
||||
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 1]
|
||||
);
|
||||
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(
|
||||
positions,
|
||||
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
|
||||
[
|
||||
1.0, 2.0, 3.0, 7.0, 12.0, 11.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,12 +330,12 @@ mod tests {
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: the tag changes nothing, the quads keep their place.
|
||||
let counts = batch.update(&entities, &[], &[], &[], false);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
|
||||
// On: the tagged quads come last, in order.
|
||||
let counts = batch.update(&entities, &[], &[], &[], true);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! copied after the test, which tells which ones it saw: each run of a page
|
||||
//! reserves room for all of its draws, the copy packs the seen ones at its
|
||||
//! start, and the run is drawn indirectly with the count the copy reached.
|
||||
use super::SolidPipeline;
|
||||
use super::arena::GRAIN;
|
||||
|
||||
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
|
||||
@@ -86,12 +87,12 @@ pub struct Call {
|
||||
pub struct Flat {
|
||||
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
|
||||
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub solid: SolidPipeline,
|
||||
/// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's
|
||||
/// `opaque_quad`): the quads with no cutout first, through a shader that never discards,
|
||||
/// then the rest.
|
||||
pub solid_opaque: wgpu::RenderPipeline,
|
||||
pub solid_cutout: wgpu::RenderPipeline,
|
||||
pub solid_opaque: SolidPipeline,
|
||||
pub solid_cutout: SolidPipeline,
|
||||
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
|
||||
pub solid_mirror: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
@@ -357,7 +358,8 @@ fn plan(
|
||||
impl Flat {
|
||||
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a
|
||||
/// terrain pipeline as the per-section ones, without the origin buffer, and without
|
||||
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`).
|
||||
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`);
|
||||
/// `solid(layout, label, vertex entry, fragment entry)` a pulled solid one.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
@@ -372,6 +374,7 @@ impl Flat {
|
||||
Option<wgpu::Face>,
|
||||
bool,
|
||||
) -> wgpu::RenderPipeline,
|
||||
solid: impl Fn(&wgpu::PipelineLayout, &str, &str, &str) -> SolidPipeline,
|
||||
) -> Self {
|
||||
let vertex = wgpu::ShaderStages::VERTEX;
|
||||
let compute = wgpu::ShaderStages::COMPUTE;
|
||||
@@ -432,32 +435,23 @@ impl Flat {
|
||||
Self {
|
||||
// The solid draws read either layout of the solid quads (`mesher::packed`) from
|
||||
// the page; translucent ones keep their vertex buffer.
|
||||
solid: pipeline(
|
||||
solid: solid(
|
||||
&layout,
|
||||
"Terrain solid (flat)",
|
||||
"vs_section_flat",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
true,
|
||||
),
|
||||
solid_opaque: pipeline(
|
||||
solid_opaque: solid(
|
||||
&layout,
|
||||
"Terrain solid opaque (flat)",
|
||||
"vs_section_flat_opaque",
|
||||
"fs_opaque",
|
||||
None,
|
||||
back,
|
||||
true,
|
||||
),
|
||||
solid_cutout: pipeline(
|
||||
solid_cutout: solid(
|
||||
&layout,
|
||||
"Terrain solid cutout (flat)",
|
||||
"vs_section_flat_cutout",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
true,
|
||||
),
|
||||
// The mirror flips the winding: its solid terrain culls the front.
|
||||
solid_mirror: pipeline(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Sorted still-fluid material: ordinary back culling, alpha blend and color/depth writes.
|
||||
pub(super) const SAMPLER: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
|
||||
label: Some("Still fluid (nearest texels, linear mipmaps)"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
lod_min_clamp: 0.0,
|
||||
lod_max_clamp: 32.0,
|
||||
compare: None,
|
||||
anisotropy_clamp: 1,
|
||||
border_color: None,
|
||||
};
|
||||
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
layout: Option<&wgpu::PipelineLayout>,
|
||||
shader: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
constants: &[(&str, f64)],
|
||||
) -> wgpu::RenderPipeline {
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Still fluid"),
|
||||
layout,
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_native_fluid"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_native_fluid"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
@@ -162,11 +162,13 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
|
||||
horizon_material_face_normal = normal;
|
||||
horizon_surface = vec4<f32>(0.0);
|
||||
let tangent = horizon_material_tangent(in.base, in.uv, normal);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
|
||||
vec4(horizon_line_in(in.tint.rgb), in.tint.a), in.light, horizon_line_in(in.lightmap),
|
||||
in.position, in.uv, translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent,
|
||||
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
|
||||
horizon_line_in(horizon_terrain_native_colored_light(in.position)), in.displaced)).color;
|
||||
return vec4(horizon_line_out(out.rgb), out.a);
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@@ -332,7 +334,7 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
|
||||
let fluid = high >= 8u && (high & 7u) < 6u;
|
||||
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
|
||||
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
|
||||
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
|
||||
vec4(horizon_line_in(color.rgb), 1.0), select(0u, high & 7u, fluid), u32(fluid)));
|
||||
}
|
||||
";
|
||||
|
||||
@@ -349,12 +351,13 @@ fn shader_source(
|
||||
source: &str,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
displaced: bool,
|
||||
(vertex, displaced): (bool, bool),
|
||||
api: horizon_shader_api::Api,
|
||||
) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
|
||||
vanilla_terrain(),
|
||||
super::native_post::line_helpers(api),
|
||||
if vertex {
|
||||
DISPLACE_PACK
|
||||
} else {
|
||||
@@ -378,8 +381,9 @@ fn module(
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
displaced: bool,
|
||||
api: horizon_shader_api::Api,
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
|
||||
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -403,6 +407,7 @@ impl NativeMaterial {
|
||||
shape.sprite_materials,
|
||||
vertex,
|
||||
displaced.is_some(),
|
||||
frame.api,
|
||||
)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
@@ -562,7 +567,7 @@ impl NativeMaterial {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
|
||||
use horizon_shader_api::{Api, MANIFEST, Pack, StageKind, check};
|
||||
|
||||
fn stage(material: &str) -> CheckedStage {
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
@@ -609,7 +614,7 @@ mod tests {
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
let materials = [(5, 1000)];
|
||||
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags, &materials, false, false).unwrap();
|
||||
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
@@ -622,8 +627,8 @@ mod tests {
|
||||
&stage.source,
|
||||
&tags,
|
||||
&materials,
|
||||
false,
|
||||
false
|
||||
(false, false),
|
||||
Api::V0_1
|
||||
))
|
||||
)
|
||||
});
|
||||
@@ -647,6 +652,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The terrain shader of a 0.2 pack with a vertex stage, whose tint the renderer decodes.
|
||||
#[test]
|
||||
fn the_terrain_validates_with_a_0_2_pack() {
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
|
||||
[material]\nfile = \"material.wgsl\"\n[vertex]\nfile = \"vertex.wgsl\"\n";
|
||||
let material = "fn material(input: MaterialInput) -> Surface {\n \
|
||||
return Surface(input.albedo * input.tint * vec4(input.colored_light, 1.0));\n}\n";
|
||||
let vertex = "fn vertex(input: VertexInput) -> vec3<f32> {\n \
|
||||
return input.position + input.tint.rgb;\n}\n";
|
||||
let files = [
|
||||
(MANIFEST, manifest),
|
||||
("material.wgsl", material),
|
||||
("vertex.wgsl", vertex),
|
||||
]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
|
||||
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_displaced_grid_validates_with_a_pack() {
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
@@ -661,14 +693,14 @@ mod tests {
|
||||
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let stage = report.stages.remove(0);
|
||||
let materials = [(5, 1000)];
|
||||
let module = module(&stage, &[], &materials, false, true).unwrap();
|
||||
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| {
|
||||
let text = shader_source(&stage.source, &[], &materials, false, true);
|
||||
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
|
||||
panic!("{}", e.emit_to_string(&text))
|
||||
});
|
||||
for entry in [
|
||||
@@ -681,4 +713,116 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An identity material (vanilla's own lighting, `albedo * tint * lightmap`) under 0.2,
|
||||
/// its colours decoded on the way in and encoded on the way out as the entries do, draws
|
||||
/// what 0.1's does (vanilla's) within an 8-bit step.
|
||||
#[test]
|
||||
fn an_identity_0_2_material_is_vanilla_s_within_an_8_bit_step() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The entries' call of `material`, with a 16×16 grid of colours.
|
||||
let entry = "@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let c = floor(p.xy) / 15.0;
|
||||
let albedo = vec4(c.x, c.y, 1.0 - c.x, 1.0);
|
||||
let tint = vec4(0.9, 1.0 - 0.5 * c.y, 0.7, 1.0);
|
||||
let lightmap = vec3(c.y, 0.8, 1.0 - c.y);
|
||||
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
|
||||
vec4(horizon_line_in(tint.rgb), tint.a), vec2(1.0), horizon_line_in(lightmap),
|
||||
vec3(0.0), vec2(0.0), false, 0u, 0u, vec2(0.0), 0u, 0u, 0.0, vec3(0.0, 1.0, 0.0),
|
||||
vec4(1.0, 0.0, 0.0, 1.0), horizon_line_in(vec3(0.0)), 0.0)).color;
|
||||
return vec4(horizon_line_out(out.rgb), out.a);
|
||||
}
|
||||
";
|
||||
let vanilla = "fn material(input: MaterialInput) -> Surface {\n \
|
||||
return Surface(input.albedo * input.tint * vec4(input.lightmap, 1.0));\n}\n";
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let draw = |api: Api| {
|
||||
let manifest = format!(
|
||||
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"{api}\"\n\
|
||||
[material]\nfile = \"material.wgsl\"\n"
|
||||
);
|
||||
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", vanilla)]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let source = report.stages[0].source.clone()
|
||||
+ horizon_shader_api::stages::PBR_STUB
|
||||
+ super::super::native_post::line_helpers(api)
|
||||
+ entry;
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(format.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let size = wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 16,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: Default::default(),
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
let screenshot = crate::gfx::copy_frame(&device, &mut encoder, &target, [16, 16]);
|
||||
queue.submit([encoder.finish()]);
|
||||
screenshot
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
screenshot.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
};
|
||||
let (old, new) = (draw(Api::V0_1), draw(Api::V0_2));
|
||||
let worst = old.iter().zip(&new).map(|(a, b)| a.abs_diff(*b)).max();
|
||||
assert!(worst.is_some_and(|w| w <= 1), "{worst:?}");
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,15 @@ impl NativePack {
|
||||
let settings = chosen
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
let api = report.manifest.api;
|
||||
let mut frame = NativeFrame::new(
|
||||
device,
|
||||
settings.as_ref(),
|
||||
textures,
|
||||
lights,
|
||||
terrain.pbr,
|
||||
api,
|
||||
);
|
||||
// `[displacement]`, unless its option is off; nothing to displace without heights.
|
||||
let displacement = report
|
||||
.manifest
|
||||
@@ -222,8 +230,13 @@ impl NativePack {
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
|
||||
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
|
||||
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
|
||||
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
|
||||
.then(|| {
|
||||
let manifest = &report.manifest;
|
||||
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
|
||||
})
|
||||
.transpose()?;
|
||||
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
|
||||
labels.extend(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
@@ -75,7 +75,7 @@ fn horizon_shadow_cutout(in: HorizonShadowOut) {
|
||||
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
if color.a < 0.01 { discard; }
|
||||
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
|
||||
return vec4(horizon_line_in(color.rgb * in.tint), select(min(color.a, 0.99), 1.0, in.fluid != 0u));
|
||||
}
|
||||
";
|
||||
|
||||
@@ -152,8 +152,13 @@ pub struct ShadowGrid<'a> {
|
||||
pub normals: &'a wgpu::TextureView,
|
||||
}
|
||||
|
||||
/// `shadow_color`'s format.
|
||||
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
/// `shadow_color`'s format: under 0.2 it holds linear light, which 8 bits would band.
|
||||
fn shadow_color(api: Api) -> wgpu::TextureFormat {
|
||||
match api {
|
||||
Api::V0_1 => wgpu::TextureFormat::Rgba8Unorm,
|
||||
Api::V0_2 => super::native_post::INTERMEDIATE,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
|
||||
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
|
||||
@@ -218,7 +223,7 @@ pub struct ShadowMap {
|
||||
}
|
||||
|
||||
impl ShadowMap {
|
||||
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
|
||||
pub fn new(device: &wgpu::Device, settings: Option<&Settings>, api: Api) -> Self {
|
||||
let size = settings.map_or(1, |s| s.resolution);
|
||||
let texture = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -250,7 +255,7 @@ impl ShadowMap {
|
||||
);
|
||||
let color = texture(
|
||||
"Native shader pack shadow colour",
|
||||
SHADOW_COLOR,
|
||||
shadow_color(api),
|
||||
wgpu::TextureUsages::empty(),
|
||||
);
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
@@ -474,6 +479,7 @@ fn shader_source(
|
||||
vertex: Option<&str>,
|
||||
materials: &[(u32, u32)],
|
||||
grid: Option<&str>,
|
||||
api: Api,
|
||||
) -> String {
|
||||
let table = super::native_material::layer_table("horizon_sprite_material", materials);
|
||||
let (head, displace) = match (grid, vertex) {
|
||||
@@ -504,11 +510,12 @@ fn shader_source(
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
|
||||
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
PREFIX
|
||||
)
|
||||
),
|
||||
super::native_post::line_helpers(api)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -527,6 +534,7 @@ impl NativeShadow {
|
||||
vertex.map(|s| s.source.as_str()),
|
||||
shape.sprite_materials,
|
||||
grid.as_ref().map(|g| g.material.source.as_str()),
|
||||
frame.api,
|
||||
);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
|
||||
@@ -613,7 +621,7 @@ impl NativeShadow {
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: SHADOW_COLOR,
|
||||
format: shadow_color(frame.api),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})];
|
||||
@@ -731,8 +739,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_shadow_shader_validates() {
|
||||
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
|
||||
let lines = [Api::V0_1, Api::V0_2];
|
||||
for (materials, api) in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]].iter().zip(lines) {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None, api);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
@@ -767,6 +776,7 @@ mod tests {
|
||||
None,
|
||||
&materials,
|
||||
Some(&stage.source),
|
||||
Api::V0_1,
|
||||
);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
|
||||
@@ -17,7 +17,8 @@ const PREFIX: &str = "horizon_sky_";
|
||||
const PACK_ENTRY: &str = "
|
||||
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
|
||||
let d = horizon_sky_view_ray(in.ndc);
|
||||
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
|
||||
let color = vec4(horizon_line_in(horizon_sky_sky_along_ray(d)), 1.0);
|
||||
return vec4(horizon_line_out(sky(SkyInput(d, color)).rgb), 1.0);
|
||||
}
|
||||
";
|
||||
|
||||
@@ -38,10 +39,11 @@ fn vanilla_sky() -> String {
|
||||
}
|
||||
|
||||
/// The sky shader for the pack's sky stage `source`.
|
||||
fn shader_source(source: &str) -> String {
|
||||
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
|
||||
vanilla_sky()
|
||||
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
|
||||
vanilla_sky(),
|
||||
super::native_post::line_helpers(api)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,7 +101,7 @@ fn build(
|
||||
frame: &NativeFrame,
|
||||
stage: &CheckedStage,
|
||||
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
|
||||
let text = shader_source(&stage.source);
|
||||
let text = shader_source(&stage.source, frame.api);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
@@ -195,7 +197,7 @@ mod tests {
|
||||
// A pack may use the renderer's names for its own things.
|
||||
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
|
||||
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
|
||||
let text = shader_source(&stage(own).source);
|
||||
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
|
||||
panic!("{}", e.emit_to_string(&text));
|
||||
});
|
||||
@@ -252,7 +254,14 @@ mod tests {
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let frame = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
horizon_shader_api::Api::V0_1,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
|
||||
@@ -454,9 +454,31 @@ impl PackEntities {
|
||||
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut without_fluids = Vec::new();
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run_start = range.start as u32;
|
||||
let whole = &entities[range];
|
||||
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
|
||||
// Keep it in its own material even when a shader pack draws the
|
||||
// model in the same block-entity run. Do not corrupt either UV space.
|
||||
let whole: &[Vertex] = if whole
|
||||
.chunks(4)
|
||||
.any(|q| q[0].layer & 0xffff == super::FLUID_OVERLAY)
|
||||
{
|
||||
without_fluids.clear();
|
||||
for quad in whole.chunks(4) {
|
||||
if quad[0].layer & 0xffff == super::FLUID_OVERLAY {
|
||||
if !span.hidden {
|
||||
rest.extend_from_slice(quad);
|
||||
}
|
||||
} else {
|
||||
without_fluids.extend_from_slice(quad);
|
||||
}
|
||||
}
|
||||
&without_fluids
|
||||
} else {
|
||||
whole
|
||||
};
|
||||
// A block entity's run can hold End portal faces and beams beside plain parts (an End
|
||||
// gateway: its cube and its beam): each goes to its own program.
|
||||
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
|
||||
@@ -1141,6 +1163,76 @@ fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32
|
||||
mod gpu_span_tests {
|
||||
use super::*;
|
||||
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
|
||||
#[test]
|
||||
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
|
||||
fn native_fluid_stays_native_while_model_uses_pack() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let layout = super::super::pack_draw::DrawUniforms::new(&device);
|
||||
let mut geometry = PackEntities::new(&device, &layout.layout);
|
||||
let mut mixed = vec![
|
||||
Vertex {
|
||||
layer: 0,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
8
|
||||
];
|
||||
for v in &mut mixed[4..] {
|
||||
v.layer = super::super::FLUID_OVERLAY;
|
||||
v.uv = [0.25, 0.75];
|
||||
v.glint_uv = [17.0, 0.0];
|
||||
}
|
||||
let mut rest = Vec::new();
|
||||
for hidden in [false, true] {
|
||||
geometry.prepare(
|
||||
&device,
|
||||
&queue,
|
||||
&layout.layout,
|
||||
&mixed,
|
||||
&[EntitySpan {
|
||||
start: 0,
|
||||
id: 77,
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
hidden,
|
||||
..Default::default()
|
||||
}],
|
||||
[true; PROGRAMS],
|
||||
([0.; 4], [1; 2]),
|
||||
Casters {
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert_eq!(
|
||||
geometry.scratch.len(),
|
||||
4,
|
||||
"the model still uses the shader pack"
|
||||
);
|
||||
assert_eq!(geometry.draws.len(), 1);
|
||||
assert_eq!(
|
||||
(geometry.draws[0].pack, geometry.draws[0].shadow),
|
||||
(!hidden, true)
|
||||
);
|
||||
if hidden {
|
||||
assert!(
|
||||
rest.is_empty(),
|
||||
"hidden native fluid must not leak into the visible pass"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&rest),
|
||||
bytemuck::cast_slice::<_, u8>(&mixed[4..]),
|
||||
"fluid UV/layer payload cannot be sampled from the entity atlas"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
|
||||
let mut skin = SkinFrame {
|
||||
|
||||
@@ -23,6 +23,13 @@ pub(super) struct PackImage {
|
||||
}
|
||||
|
||||
impl PackImage {
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
self.view = None;
|
||||
self.copy = None;
|
||||
self.before = None;
|
||||
self.made_for = [0; 2];
|
||||
}
|
||||
|
||||
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
|
||||
self.format
|
||||
.sample_type(None, Some(features))
|
||||
@@ -316,6 +323,11 @@ impl StandIn {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reset_stand_ins(device: &wgpu::Device, images: &mut Vec<StandIn>) {
|
||||
let wanted = images.iter().map(|i| (i.format, i.dimension)).collect();
|
||||
*images = stand_ins(device, wanted);
|
||||
}
|
||||
|
||||
/// One stand-in for each format and dimension of `wanted`.
|
||||
pub(super) fn stand_ins(
|
||||
device: &wgpu::Device,
|
||||
@@ -392,6 +404,12 @@ pub(super) struct Buffers {
|
||||
}
|
||||
|
||||
impl Buffers {
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
self.group = None;
|
||||
self.buffers.clear();
|
||||
self.made_for = [0; 2];
|
||||
}
|
||||
|
||||
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
|
||||
/// `minimums` long.
|
||||
pub(super) fn new(
|
||||
|
||||
@@ -31,6 +31,9 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
|
||||
mod packed;
|
||||
|
||||
#[cfg(test)]
|
||||
mod history_reset_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_target_tests;
|
||||
|
||||
@@ -43,6 +46,9 @@ mod higher_shadowcolor_tests;
|
||||
#[cfg(test)]
|
||||
mod single_side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mixed_size_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod sky_disc_blend_tests;
|
||||
|
||||
@@ -399,6 +405,8 @@ struct Pass {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
/// Shader-declared defaults must survive a reset of the mutable upload block.
|
||||
initial_block: Vec<u8>,
|
||||
/// (index into `pack_uniforms::NAMES`, declared type, offset).
|
||||
uniforms: Vec<(Option<usize>, String, u32)>,
|
||||
samplers: Vec<Binding>,
|
||||
@@ -452,6 +460,16 @@ struct Pass {
|
||||
}
|
||||
|
||||
impl Pass {
|
||||
/// Back to the uniform values the pass was made with, and no bind groups.
|
||||
fn reset_history(&mut self) {
|
||||
self.block.clone_from(&self.initial_block);
|
||||
self.groups.clear();
|
||||
self.portal_groups.clear();
|
||||
for compute in &mut self.compute {
|
||||
compute.reset_history();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) -> &wgpu::RenderPipeline {
|
||||
match &self.pipeline {
|
||||
Pipeline::Render(p) => p,
|
||||
@@ -1973,6 +1991,7 @@ fn build_pass(
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: interface.initial_values.clone(),
|
||||
initial_block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -2108,6 +2127,7 @@ fn build_compute(
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: interface.initial_values.clone(),
|
||||
initial_block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -2379,7 +2399,16 @@ impl PackPost {
|
||||
} else {
|
||||
written.next()
|
||||
};
|
||||
if !in_shadow && group != Group::Final && written.any(|s| Some(s) != first) {
|
||||
// Sizes of one kind that differ never meet. A size relative to the render size and
|
||||
// one in pixels can: the pass then draws while they resolve to one size
|
||||
// (`PackPost::sizes_differ`). Iris 1.11.7 drew a composite program writing
|
||||
// colortex2 at `0.25 0.25` and colortex3 at `320 180` at 1280×720 (#10454).
|
||||
let apart = |s: horizon_shaders::BufferSize| {
|
||||
first.is_some_and(|f| {
|
||||
f != s && (depth || std::mem::discriminant(&f) == std::mem::discriminant(&s))
|
||||
})
|
||||
};
|
||||
if !in_shadow && group != Group::Final && written.any(apart) {
|
||||
notes.push(format!(
|
||||
"{}: writes buffers of different sizes (size.buffer); skipped",
|
||||
pass.name
|
||||
@@ -3115,23 +3144,7 @@ impl PackPost {
|
||||
.create_view(&Default::default()),
|
||||
textures,
|
||||
shadow: (
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default()),
|
||||
super::pack_shadow::stand_in(device),
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack shadow"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
@@ -3180,6 +3193,65 @@ impl PackPost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Forgets what the pack kept from earlier frames, so the next frame draws as a freshly
|
||||
/// loaded pack's first: buffers, images, storage buffers and shadow maps start over (made
|
||||
/// again at their sizes when next drawn), the frame uniforms' history, the custom uniforms
|
||||
/// and the centre depth too. Compiled programs and the pack's textures stay.
|
||||
pub fn reset_history(&mut self, device: &wgpu::Device) {
|
||||
self.uniforms.reset_history();
|
||||
// A parsed pack's custom uniforms start at tick 0's seed, before any frame of the world.
|
||||
self.custom.restart_at(0);
|
||||
self.custom.forget_world();
|
||||
self.center_depth = None;
|
||||
self.targets = None;
|
||||
self.main = 0;
|
||||
self.mips_valid = 0;
|
||||
self.rgb_unset = true;
|
||||
self.final_target = None;
|
||||
self.blit_group = None;
|
||||
self.page_groups.clear();
|
||||
for image in &mut self.images {
|
||||
image.reset_history();
|
||||
}
|
||||
self.buffers.reset_history();
|
||||
pack_images::reset_stand_ins(device, &mut self.stand_ins);
|
||||
if let Some(shadow) = &mut self.shadow_targets {
|
||||
shadow.reset_history(device, &self.uniforms.shadow);
|
||||
}
|
||||
self.shadow.0 = super::pack_shadow::stand_in(device);
|
||||
self.shadow_cleared = false;
|
||||
self.shadow_cache = Default::default();
|
||||
self.setup_pending = true;
|
||||
self.portal_texture = None;
|
||||
self.portal_rect = [0.0; 4];
|
||||
let sky = self
|
||||
.sky
|
||||
.iter_mut()
|
||||
.flat_map(|s| s.basic.iter_mut().chain(&mut s.textured));
|
||||
let entities = self
|
||||
.entities
|
||||
.iter_mut()
|
||||
.flat_map(|e| e.passes.iter_mut().chain(&mut e.shadow).flatten());
|
||||
for pass in self
|
||||
.passes
|
||||
.iter_mut()
|
||||
.chain(&mut self.terrain)
|
||||
.chain(&mut self.terrain_cutout)
|
||||
.chain(&mut self.water)
|
||||
.chain(sky)
|
||||
.chain(entities)
|
||||
.chain(&mut self.clouds)
|
||||
.chain(&mut self.lines)
|
||||
.chain(self.weather.iter_mut().flatten())
|
||||
.chain(self.hand.iter_mut().map(|h| &mut h.0))
|
||||
.chain(&mut self.shadow_terrain)
|
||||
.chain(&mut self.shadow_water)
|
||||
.chain(&mut self.shadow_comp)
|
||||
{
|
||||
pass.reset_history();
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
|
||||
/// current mapping. The buffers it both samples and stores to are copied first, and it
|
||||
/// samples the copies.
|
||||
@@ -4291,6 +4363,18 @@ impl PackPost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the buffers `pass` writes have different sizes at the current render size: then
|
||||
/// it is skipped, as one whose `size.buffer` sizes never meet is when the pack loads. Only
|
||||
/// a pass writing buffers sized relative to the render size and in pixels can.
|
||||
fn sizes_differ(&self, pass: &Pass) -> bool {
|
||||
let Some(targets) = &self.targets else {
|
||||
return false;
|
||||
};
|
||||
let mut sizes = pass.writes.iter().map(|&(_, b)| targets.sizes[b]);
|
||||
let first = sizes.next();
|
||||
!pass.group.shadow() && pass.group != Group::Final && sizes.any(|s| Some(s) != first)
|
||||
}
|
||||
|
||||
/// Whether the pack draws terrain itself, or has nothing draw it.
|
||||
pub fn has_terrain(&self) -> bool {
|
||||
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
|
||||
@@ -4532,6 +4616,14 @@ impl PackPost {
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
for pass in self.passes.iter().filter(|p| self.sizes_differ(p)) {
|
||||
log::info!(
|
||||
"shader pack: {}: writes buffers of different sizes at {}x{} (size.buffer); skipped",
|
||||
pass.name,
|
||||
size[0],
|
||||
size[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The shadow pass (2.1, passes 2 and 3): the shadow buffers cleared, then the `shadowcomp`
|
||||
@@ -5078,6 +5170,10 @@ impl PackPost {
|
||||
terrain_drawn = true;
|
||||
self.draw_opaque(device, queue, encoder, size, frame, terrain);
|
||||
}
|
||||
// As if the pack didn't have it: no compute stages, flips or stage changes.
|
||||
if self.sizes_differ(pass) {
|
||||
continue;
|
||||
}
|
||||
if !water_drawn && matches!(pass.group, Group::Composite | Group::Final) {
|
||||
water_drawn = true;
|
||||
self.draw_translucent(device, queue, encoder, size, terrain);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! `PackPost::reset_history`: a pack reset after some frames draws as a freshly loaded one.
|
||||
use super::destination_tests::Fixture;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU with compute and fragment storage"]
|
||||
fn a_reset_pack_draws_its_buffers_images_storage_and_shadow_from_the_start() {
|
||||
let gpu = Fixture::new().expect("a GPU is required for this reset regression");
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
let vertex = "#version 120\nvarying vec2 uv;\nvoid main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}\n";
|
||||
let state = "layout(std430,binding=0) buffer State { uint setups; uint frames; } state;\n";
|
||||
let compute = "#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(1);\n";
|
||||
add(
|
||||
"setup.csh",
|
||||
&format!("{compute}{state}void main(){{state.setups+=1u;}}\n"),
|
||||
);
|
||||
add(
|
||||
"begin.csh",
|
||||
&format!(
|
||||
"{compute}{state}layout(r32ui) uniform uimage2D imgCounter;\n\
|
||||
void main(){{state.frames+=1u;imageStore(imgCounter,ivec2(0),uvec4(imageLoad(imgCounter,ivec2(0)).r+1u));}}\n"
|
||||
),
|
||||
);
|
||||
for name in ["composite", "shadowcomp"] {
|
||||
add(&format!("{name}.vsh"), vertex);
|
||||
let target = if name == "composite" { 2 } else { 0 };
|
||||
let (declarations, value) = if name == "composite" {
|
||||
("", "vec4(0.125)")
|
||||
} else {
|
||||
(
|
||||
"uniform sampler2D shadowcolor0;varying vec2 uv;",
|
||||
"texture2D(shadowcolor0,uv)+vec4(0.125)",
|
||||
)
|
||||
};
|
||||
add(
|
||||
&format!("{name}.fsh"),
|
||||
&format!(
|
||||
"#version 120\n/* RENDERTARGETS:{target} */\n\
|
||||
{declarations}\nvoid main(){{gl_FragData[0]={value};}}\n"
|
||||
),
|
||||
);
|
||||
}
|
||||
add("final.vsh", vertex);
|
||||
add(
|
||||
"final.fsh",
|
||||
&format!(
|
||||
"#version 430 compatibility\n{state}\
|
||||
uniform sampler2D colortex2;uniform sampler2D shadowcolor0;uniform usampler2D texCounter;\n\
|
||||
const bool colortex2Clear=false;const bool shadowcolor0Clear=false;\n\
|
||||
const int shadowMapResolution=16;uniform float baseline=0.375;varying vec2 uv;\n\
|
||||
void main(){{gl_FragColor=gl_FragCoord.x<8.0?\
|
||||
vec4(texture(colortex2,uv).r,float(texelFetch(texCounter,ivec2(0),0).r)/8.0,float(state.frames)/8.0,1):\
|
||||
vec4(texture(shadowcolor0,uv).r,float(state.setups)/8.0,baseline,1);}}\n"
|
||||
),
|
||||
);
|
||||
add(
|
||||
"shaders.properties",
|
||||
"shadow.enabled=true\nbufferObject.0=8\n\
|
||||
image.imgCounter=texCounter RED_INTEGER R32UI UNSIGNED_INT false false 1 1\n\
|
||||
blend.composite=ONE ONE ONE ONE\n",
|
||||
);
|
||||
let pack =
|
||||
horizon_shaders::Pack::from_zip_bytes("original-reset", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let load = || horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
let mut post = gpu.try_build(load()).expect("reset fixture compiles");
|
||||
let mut fresh = gpu.try_build(load()).expect("fresh control compiles");
|
||||
let check = |pixels: &[[u8; 4]], frame: u8| {
|
||||
let counter = 32 * frame;
|
||||
for (i, pixel) in pixels.iter().enumerate() {
|
||||
let expected = if i % 16 < 8 {
|
||||
[counter, counter, counter, 255]
|
||||
} else {
|
||||
[counter, 32, 96, 255]
|
||||
};
|
||||
assert!(
|
||||
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1),
|
||||
"frame {frame}, pixel {i}: {pixel:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
};
|
||||
for frame in 1..=3 {
|
||||
check(&gpu.render(&mut post, [16, 16]), frame);
|
||||
}
|
||||
// At the same size too: the retained colortex2 and shadowcolor0, the image and
|
||||
// the storage buffer start over, and setup runs once again. The control is a pack never
|
||||
// reset.
|
||||
for _ in 0..2 {
|
||||
post.reset_history(&gpu.device);
|
||||
for frame in 1..=2 {
|
||||
let pixels = gpu.render(&mut post, [16, 16]);
|
||||
check(&pixels, frame);
|
||||
if frame == 1 {
|
||||
assert_eq!(pixels, gpu.render(&mut fresh, [16, 16]));
|
||||
}
|
||||
}
|
||||
fresh.reset_history(&gpu.device);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! A composite program writing two buffers whose `size.buffer` lines declare one relative to the
|
||||
//! render size and one in pixels (#10454). Iris 1.11.7 drew such a program where both came to
|
||||
//! the same size (our probe pack: `0.25 0.25` and `320 180` at 1280×720). The programs here are
|
||||
//! original and cut down.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// `composite` writes (0.25, 0.5, 0.75) into colortex2, sized `0.25 0.25`, and colortex3, sized
|
||||
/// `colortex3` (`size.buffer` words); `final` shows colortex2's red and green and colortex3's
|
||||
/// blue.
|
||||
fn pack(colortex3: &str) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add(
|
||||
"shaders.properties",
|
||||
&format!("size.buffer.colortex2 = 0.25 0.25\nsize.buffer.colortex3 = {colortex3}\n"),
|
||||
);
|
||||
add("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 2,3 */\nvoid main() {\n\
|
||||
gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\ngl_FragData[1] = vec4(0.25, 0.5, 0.75, 1.0);\n}\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex3;\nvarying vec2 uv;\n\
|
||||
void main() {\ngl_FragColor = vec4(texture2D(colortex2, uv).rg, texture2D(colortex3, uv).b, 1.0);\n}\n",
|
||||
);
|
||||
let pack =
|
||||
horizon_shaders::Pack::from_zip_bytes("mixed-sizes", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default())
|
||||
}
|
||||
|
||||
/// Every pixel's colour, with how many pixels differ from it by more than one step.
|
||||
fn one_colour(pixels: &[[u8; 4]]) -> ([u8; 4], usize) {
|
||||
let first = pixels[0];
|
||||
let off = pixels
|
||||
.iter()
|
||||
.filter(|p| p.iter().zip(first).any(|(a, b)| a.abs_diff(b) > 1))
|
||||
.count();
|
||||
(first, off)
|
||||
}
|
||||
|
||||
/// At 16×16 both buffers are 4×4: the program draws. At 32×32 colortex2 is 8×8 and colortex3
|
||||
/// still 4×4: the program is skipped there, as one whose sizes never meet is (Iris refused
|
||||
/// the whole pack in that case; Horizon skips only the program).
|
||||
#[test]
|
||||
fn a_program_writing_buffers_that_come_to_one_size_draws() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack("4 4"));
|
||||
let (colour, off) = one_colour(&gpu.render(&mut post, [16, 16]));
|
||||
assert_eq!(off, 0, "{colour:?}");
|
||||
for (got, want) in colour.iter().zip([64, 128, 191, 255]) {
|
||||
assert!(got.abs_diff(want) <= 1, "{colour:?}");
|
||||
}
|
||||
let (colour, off) = one_colour(&gpu.render(&mut post, [32, 32]));
|
||||
assert_eq!((colour, off), ([0, 0, 0, 255], 0));
|
||||
}
|
||||
|
||||
/// Sizes of one kind that differ never meet: the program is skipped as the pack loads.
|
||||
#[test]
|
||||
fn a_program_writing_buffers_of_different_relative_sizes_is_skipped() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let post = pack("0.5 0.5");
|
||||
let post = gpu
|
||||
.try_build(post)
|
||||
.unwrap_or_else(|_| panic!("the pack runs"));
|
||||
assert!(
|
||||
post.notes
|
||||
.iter()
|
||||
.any(|n| n == "composite: writes buffers of different sizes (size.buffer); skipped"),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,50 @@ fn target(
|
||||
(texture, view)
|
||||
}
|
||||
|
||||
/// The depth stand-in is initialized to no shadow on the next pack render.
|
||||
pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
impl ShadowTargets {
|
||||
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
|
||||
let mut colors = 0;
|
||||
let mut doubled = 0;
|
||||
let formats = std::array::from_fn(|n| match &self.color[n] {
|
||||
Some([a, b]) => {
|
||||
colors |= 1 << n;
|
||||
if a.texture() != b.texture() {
|
||||
doubled |= 1 << n;
|
||||
}
|
||||
a.texture().format()
|
||||
}
|
||||
None => wgpu::TextureFormat::Rgba8Unorm,
|
||||
});
|
||||
*self = Self::new(
|
||||
device,
|
||||
shadow,
|
||||
formats,
|
||||
colors,
|
||||
doubled,
|
||||
self.depth1.is_some(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
|
||||
/// some program reads `shadowtex1`, `doubled` (bit n: shadowcolor n needs a B texture). The
|
||||
/// resolution is kept within the device's limit.
|
||||
|
||||
@@ -709,6 +709,16 @@ fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
|
||||
}
|
||||
|
||||
impl FrameUniforms {
|
||||
/// Forgets the frames before (the previous camera, smoothed values, the clock and the
|
||||
/// centre depth), keeping the pack's atlas size and shadow settings.
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
*self = Self {
|
||||
atlas: self.atlas,
|
||||
shadow: std::mem::take(&mut self.shadow),
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
|
||||
/// This frame's values for a render size of `size` pixels.
|
||||
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
|
||||
let now = std::time::Instant::now();
|
||||
@@ -2367,6 +2377,61 @@ pub(super) mod initial_history_tests {
|
||||
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
|
||||
}
|
||||
|
||||
/// A reset pack draws its next frame as its first: no previous camera, smoothed values
|
||||
/// at their targets, the clock from 0; the atlas size it was given stays.
|
||||
#[test]
|
||||
fn a_reset_forgets_the_uniforms_history_and_keeps_the_pack_s_settings() {
|
||||
let hud = HudBatch::new([16, 16]);
|
||||
let mut f = frame(&hud);
|
||||
let mut uniforms = FrameUniforms {
|
||||
atlas: [256, 128],
|
||||
..Default::default()
|
||||
};
|
||||
let mut inputs = PackInputs {
|
||||
rain: 1.0,
|
||||
eye_light: [15, 15],
|
||||
frame_timing: Some(FrameTiming {
|
||||
counter: 800,
|
||||
elapsed: 99.0,
|
||||
delta: 1.0,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
uniforms.center_depth_raw = Some(0.25);
|
||||
uniforms.update(&f, &inputs, [16, 16]);
|
||||
f.camera = glam::DVec3::new(7.0, 12.0, -30.0);
|
||||
f.yaw = 77.0;
|
||||
inputs.rain = 0.25;
|
||||
inputs.eye_light = [3, 8];
|
||||
inputs.frame_timing = None;
|
||||
uniforms.reset_history();
|
||||
uniforms.update(&f, &inputs, [16, 16]);
|
||||
let float = |name| match uniforms.values[index(name).unwrap()] {
|
||||
Value::F(v) => v,
|
||||
_ => panic!("float uniform expected"),
|
||||
};
|
||||
let int = |name| match uniforms.values[index(name).unwrap()] {
|
||||
Value::I(v) => v,
|
||||
_ => panic!("integer uniform expected"),
|
||||
};
|
||||
assert_eq!(int("frameCounter")[0], 0);
|
||||
assert_eq!(float("frameTime")[0], 0.0);
|
||||
assert_eq!(int("atlasSize")[..2], [256, 128]);
|
||||
assert_eq!(float("wetness")[0], 0.25);
|
||||
assert_eq!(int("eyeBrightnessSmooth")[..2], [48, 128]);
|
||||
assert_eq!(float("centerDepthSmooth")[0], 1.0);
|
||||
for (previous, current) in [
|
||||
("previousCameraPosition", "cameraPosition"),
|
||||
("gbufferPreviousModelView", "gbufferModelView"),
|
||||
("gbufferPreviousProjection", "gbufferProjection"),
|
||||
] {
|
||||
assert_eq!(
|
||||
float(previous).map(f32::to_bits),
|
||||
float(current).map(f32::to_bits)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
|
||||
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
|
||||
#[test]
|
||||
|
||||
@@ -323,4 +323,27 @@ mod tests {
|
||||
let unorm = wgpu::TextureFormat::Rgba16Unorm;
|
||||
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
|
||||
}
|
||||
|
||||
/// `Rgba16Float`, the frame of a shader API 0.2 pack, or of HDR on without a pack, under
|
||||
/// `HORIZON_HDR_FRAME=float` (`Renderer::frame_format`): what is left over 1 is clipped on
|
||||
/// the way.
|
||||
#[test]
|
||||
fn a_float_frame_reaches_an_8_bit_swapchain() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let color = wgpu::Color {
|
||||
r: 1.5,
|
||||
g: 0.2,
|
||||
b: -0.5,
|
||||
a: 1.0,
|
||||
};
|
||||
let float = wgpu::TextureFormat::Rgba16Float;
|
||||
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
|
||||
}
|
||||
}
|
||||
@@ -217,15 +217,19 @@ struct HorizonWeatherOut {
|
||||
textureDimensions(horizon_entity_atlas));
|
||||
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
|
||||
if color.a < 0.1 { discard; }
|
||||
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
|
||||
let input = WeatherInput(vec4(horizon_line_in(color.rgb), color.a), in.light, in.position,
|
||||
in.snow != 0u);
|
||||
let out = weather(input);
|
||||
return vec4(horizon_line_out(out.rgb), out.a);
|
||||
}
|
||||
";
|
||||
|
||||
/// The shader of a native pack's weather stage `source`.
|
||||
fn native_source(source: &str) -> String {
|
||||
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
|
||||
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
|
||||
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
|
||||
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
|
||||
super::native_post::line_helpers(api)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -242,7 +246,7 @@ pub(super) fn native_pipeline(
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<wgpu::RenderPipeline, String> {
|
||||
use horizon_shader_api::stages::GROUP;
|
||||
let text = native_source(&stage.source);
|
||||
let text = native_source(&stage.source, frame.api);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
|
||||
let present = super::native_post::present_constants(&module, constants);
|
||||
@@ -318,19 +322,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_native_weather_stage_validates() {
|
||||
use horizon_shader_api::stages::StageKind;
|
||||
let source = StageKind::Weather.prelude(horizon_shader_api::Api::V0_1)
|
||||
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
|
||||
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
|
||||
let text = native_source(&source);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
use horizon_shader_api::{Api, stages::StageKind};
|
||||
for api in [Api::V0_1, Api::V0_2] {
|
||||
let source = StageKind::Weather.prelude(api)
|
||||
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
|
||||
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
|
||||
let text = native_source(&source, api);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
}
|
||||
use crate::weather::{Parameters, Time};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
@@ -1092,8 +1092,9 @@ impl Graphics {
|
||||
// (horizon_threads: niced on Linux; on macOS and Windows the server
|
||||
// and render threads are raised above four cores, where vanilla
|
||||
// raises its server thread). Leaving two idled a core of a 4-thread
|
||||
// laptop while a world loaded: a teleport there took ~15% longer.
|
||||
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
|
||||
// laptop while a world loaded: a teleport there took ~15% longer. On Apple
|
||||
// silicon only the performance cores count (horizon_threads::default_workers).
|
||||
let auto = horizon_threads::jobs::default_threads();
|
||||
if self.battery { auto.div_ceil(2) } else { auto }
|
||||
}
|
||||
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
|
||||
@@ -1476,7 +1477,10 @@ mod tests {
|
||||
let g = Graphics::default();
|
||||
assert_eq!(g.workers, 0);
|
||||
let cores = horizon_threads::worker_count();
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
|
||||
if !cfg!(target_vendor = "apple") {
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
}
|
||||
// A manual count is the player's own choice, kept as asked.
|
||||
let manual = Graphics { workers: 3, ..g };
|
||||
assert_eq!(manual.worker_threads(), 3);
|
||||
|
||||
@@ -639,12 +639,20 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
/// BlueMap download radii, in chunks.
|
||||
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
|
||||
/// What the LOD Distance slider stops at, in chunks: off, fine steps up to 256
|
||||
/// for a short ring of far terrain past a small render distance (a phone or an
|
||||
/// integrated GPU at 8 chunks wants LOD to 12 or 16, not 64), then each doubling
|
||||
/// up to the whole world.
|
||||
const LOD_STEPS: [u32; 18] = [
|
||||
const LOD_STEPS: [u32; 24] = [
|
||||
0,
|
||||
16,
|
||||
24,
|
||||
32,
|
||||
48,
|
||||
64,
|
||||
96,
|
||||
128,
|
||||
192,
|
||||
256,
|
||||
512,
|
||||
1024,
|
||||
@@ -2692,12 +2700,21 @@ mod tests {
|
||||
item.label(&s).plain(),
|
||||
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
|
||||
);
|
||||
item.set_slider(&mut s, 5.0 / 17.0);
|
||||
item.set_slider(&mut s, 11.0 / 23.0);
|
||||
assert_eq!(s.horizon.lod_distance, 1024);
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
|
||||
// The fine stops: a ring of far terrain just past a small render
|
||||
// distance, which the doubling steps skipped (#9694).
|
||||
item.set_slider(&mut s, 1.0 / 23.0);
|
||||
assert_eq!(s.horizon.lod_distance, 16);
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: 16 chunks");
|
||||
item.set_slider(&mut s, 2.0 / 23.0);
|
||||
assert_eq!(s.horizon.lod_distance, 24);
|
||||
// A distance between stops (from the file) sits on the next one.
|
||||
s.horizon.lod_distance = 3000;
|
||||
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
|
||||
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
|
||||
s.horizon.lod_distance = 12;
|
||||
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
|
||||
assert_eq!(
|
||||
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
|
||||
format!("Full Detail Distance: {near} chunks")
|
||||
|
||||
@@ -61,9 +61,10 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
|
||||
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
|
||||
// would read differently (an alpha of 0.001 would count as the hand); its colour
|
||||
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
|
||||
// keeps the gradients 8 bits would band, and a float frame's (LINEAR_FRAME) the
|
||||
// light over 1 that blends added past white. On an 8-bit frame nothing changes.
|
||||
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
|
||||
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
|
||||
return vec4(max(v.rgb, vec3(0.0)), round(saturate(v.a) * 255.0) / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
@@ -161,15 +162,12 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
// 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 = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
|
||||
@@ -179,11 +177,19 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
var lifted = false;
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(c, w.rgb)));
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
// at white before their lift, as on a unorm frame, so light sources, the sun and
|
||||
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
|
||||
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(c, vec3(2.2));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail.
|
||||
if shows && w.a >= 0.75 {
|
||||
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
|
||||
// gets no gain and no knee: the clamp to the peak below is all it needs here.
|
||||
if shows && w.a >= 0.75 && w.a < 1.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let v = top * gain;
|
||||
@@ -214,6 +220,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
light = min(light, vec3(params.peak));
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
let scrgb = light * params.paper;
|
||||
|
||||
@@ -1354,6 +1354,11 @@ impl HudAtlas {
|
||||
bordered.insert(image.0.clone());
|
||||
}
|
||||
}
|
||||
if full {
|
||||
let (width, height, rgba) =
|
||||
decode_bytes(include_bytes!("jei_recipes/config_button.png"))?;
|
||||
images.push(("horizon/recipes/settings".to_owned(), width, height, rgba));
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let (width, placements, glyph_region) =
|
||||
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
|
||||
|
||||
@@ -485,11 +485,6 @@ pub struct HealthHud {
|
||||
/// `Util.getMillis()` of the last health change.
|
||||
pub last_health_time: i64,
|
||||
pub health_blink_time: i64,
|
||||
/// `Hud.leftHeight` and `rightHeight` once the health, armor, food, mount
|
||||
/// and air layers have run; the selected item name and the action bar sit
|
||||
/// above them.
|
||||
pub left_height: i32,
|
||||
pub right_height: i32,
|
||||
/// `Hud.lastBubblePopSoundPlayed`.
|
||||
pub last_bubble_pop_sound: i32,
|
||||
/// The `ui.hud.bubble_pop` volume and pitch `playAirBubblePoppedSound`
|
||||
@@ -655,9 +650,6 @@ impl HealthHud {
|
||||
let rows = ceil((max_health + absorption as f32) / 2.0 / 10.0);
|
||||
let row_height = (10 - (rows - 2)).max(3);
|
||||
let mut y_air = y_base - 10;
|
||||
self.left_height = 39 + (rows - 1) * row_height + 10 + if i.armor > 0 { 10 } else { 0 };
|
||||
// The mount's heart rows (`extractVehicleHealth`); food adds its row below.
|
||||
self.right_height = 39 + 10 * ((i.vehicle_hearts + 9) / 10);
|
||||
let offset_index = if i.regeneration {
|
||||
i.tick % ceil(max_health + 5.0)
|
||||
} else {
|
||||
@@ -752,13 +744,11 @@ impl HealthHud {
|
||||
}
|
||||
}
|
||||
y_air -= 10;
|
||||
self.right_height += 10;
|
||||
}
|
||||
// Hud.extractAirBubbles.
|
||||
let max_air = i.max_air.max(1);
|
||||
let air = i.air.clamp(0, max_air);
|
||||
if i.under_water || air < max_air {
|
||||
self.right_height += 10;
|
||||
let rows = (f64::from(i.vehicle_hearts) / 10.0).ceil() as i32;
|
||||
let y = y_air - (rows - 1) * 10;
|
||||
let bubble = |offset: i32| ceil(((air + offset) * 10) as f32 / max_air as f32);
|
||||
|
||||
@@ -115,8 +115,7 @@ impl From<&crate::effects::EffectInstance> for EffectIcon {
|
||||
color: def.map_or(0, |d| d.color as i32),
|
||||
ambient: effect.ambient,
|
||||
duration: effect.duration,
|
||||
// Horizon keeps no showIcon flag: every effect shows its icon.
|
||||
show_icon: true,
|
||||
show_icon: effect.show_icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,13 +322,12 @@ impl Titles {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Hud.extractOverlayMessage`; `bar_height` is
|
||||
/// `max(leftHeight, rightHeight)`.
|
||||
/// `Hud.extractOverlayMessage`: the action bar keeps its place however
|
||||
/// tall the health, food, air and mount rows above the hotbar are.
|
||||
pub fn overlay_message(
|
||||
&self,
|
||||
w: i32,
|
||||
h: i32,
|
||||
bar_height: i32,
|
||||
partial: f32,
|
||||
font: &Font,
|
||||
background: u32,
|
||||
@@ -345,12 +343,11 @@ impl Titles {
|
||||
let alpha = ((t * 255.0 / 20.0) as i32).min(255);
|
||||
if alpha > 0 {
|
||||
let message = message.resolve_legacy();
|
||||
// The bars' height plus the gap between the item name (59) and the
|
||||
// overlay (68), at least the default level.
|
||||
let y_shift = bar_height + (68 - 59);
|
||||
// `Hud.extractOverlayMessage` translates to `guiHeight - 68`, a
|
||||
// constant the bars above the hotbar never move.
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(
|
||||
(w / 2) as f32,
|
||||
(h - y_shift.max(68)) as f32,
|
||||
(h - 68) as f32,
|
||||
)]));
|
||||
let color = if self.overlay_animated {
|
||||
hsv_to_argb(t / 50.0, 0.7, 0.6, alpha as u32)
|
||||
@@ -468,13 +465,13 @@ impl ToolHighlight {
|
||||
self.last = selected;
|
||||
}
|
||||
/// `Hud.extractSelectedItemName`; `can_hurt` is
|
||||
/// `gameMode.canHurtPlayer()` (survival and adventure) and `bar_height`
|
||||
/// is `max(leftHeight, rightHeight)`.
|
||||
/// `gameMode.canHurtPlayer()` (survival and adventure). The name sits 59
|
||||
/// pixels above the bottom edge whatever the bars above the hotbar do,
|
||||
/// and 14 lower when the mode cannot hurt the player.
|
||||
pub fn draw(
|
||||
&self,
|
||||
w: i32,
|
||||
h: i32,
|
||||
bar_height: i32,
|
||||
can_hurt: bool,
|
||||
font: &Font,
|
||||
background: u32,
|
||||
@@ -495,7 +492,7 @@ impl ToolHighlight {
|
||||
}
|
||||
let str_width = width(font, &name);
|
||||
let x = (w - str_width) / 2;
|
||||
let mut y = h - bar_height.max(59);
|
||||
let mut y = h - 59;
|
||||
if !can_hurt {
|
||||
y += 14;
|
||||
}
|
||||
@@ -1295,3 +1292,92 @@ mod subtitle_tests {
|
||||
assert!(!o.listening());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod text_position_tests {
|
||||
use super::*;
|
||||
use crate::{font::Font, hud::Rect};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// The missing-glyph metrics let these tests run without game assets.
|
||||
fn font() -> Font {
|
||||
Font::load(
|
||||
Path::new("/nonexistent-horizon-hud-test-resources"),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn held() -> HeldName {
|
||||
HeldName {
|
||||
item: "minecraft:stone".into(),
|
||||
hover_name: Text::literal("Stone"),
|
||||
rarity_color: 0xFFFF_FFFF,
|
||||
custom_name: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_top(draws: &[GuiDraw]) -> Option<i32> {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Text { y, .. } => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `Hud.extractSelectedItemName`: `guiHeight - 59`, then `+ 14` when the
|
||||
/// game mode cannot hurt the player. The health, food, air and mount rows
|
||||
/// above the hotbar never move it: with armor and a second row of hearts
|
||||
/// they reach 69 pixels, and the name stays where vanilla draws it.
|
||||
#[test]
|
||||
fn the_selected_item_name_sits_fifty_nine_above_the_bottom_edge() {
|
||||
let mut highlight = ToolHighlight {
|
||||
last: Some(held()),
|
||||
timer: 40,
|
||||
};
|
||||
let f = font();
|
||||
let draws = highlight.draw(320, 240, true, &f, 0);
|
||||
assert_eq!(text_top(&draws), Some(240 - 59));
|
||||
// Creative and spectator add the fourteen pixels the experience bar
|
||||
// and the food row would have taken.
|
||||
let draws = highlight.draw(320, 240, false, &f, 0);
|
||||
assert_eq!(text_top(&draws), Some(240 - 59 + 14));
|
||||
// No timer, no name.
|
||||
highlight.timer = 0;
|
||||
assert!(highlight.draw(320, 240, true, &f, 0).is_empty());
|
||||
}
|
||||
|
||||
/// `Hud.extractOverlayMessage`: the action bar and its text translate to
|
||||
/// `guiHeight - 68` and `-4`, again wherever the bars above the hotbar
|
||||
/// reach.
|
||||
#[test]
|
||||
fn the_action_bar_sits_sixty_eight_above_the_bottom_edge() {
|
||||
let mut titles = Titles::default();
|
||||
titles.set_overlay(Text::literal("Action bar"), false);
|
||||
let f = font();
|
||||
let draws = titles.overlay_message(320, 240, 0.0, &f, 0);
|
||||
let translate = draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
|
||||
PoseOp::Translate(_, y) => Some(*y),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(translate, Some((240 - 68) as f32));
|
||||
// The text is drawn four pixels below that origin, as vanilla's is.
|
||||
let relative = draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Text { y, .. } => Some(*y),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(relative, Some(-4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2015 mezz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 169 B |
@@ -36,12 +36,14 @@ pub mod connected;
|
||||
pub mod containers;
|
||||
pub mod controller_ui;
|
||||
pub mod crash;
|
||||
pub mod custom_sky;
|
||||
pub mod datapack;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod environment;
|
||||
pub mod extras;
|
||||
pub mod fast_hash;
|
||||
pub mod fluid_render;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod fusion;
|
||||
|
||||
@@ -274,6 +274,8 @@ enum Out {
|
||||
date: NaiveDate,
|
||||
lines: Vec<String>,
|
||||
stderr: Option<String>,
|
||||
/// The message's level, thread and text, for the [`set_line_hook`] callback.
|
||||
record: (log::Level, String, String),
|
||||
},
|
||||
/// Answer once everything before is written.
|
||||
Flush(mpsc::SyncSender<()>),
|
||||
@@ -293,12 +295,14 @@ struct Writer {
|
||||
impl Writer {
|
||||
fn run(mut self, out: mpsc::Receiver<Out>) {
|
||||
let stderr = io::stderr();
|
||||
let mut hook_alive = true;
|
||||
for msg in out {
|
||||
match msg {
|
||||
Out::Write {
|
||||
date,
|
||||
lines,
|
||||
stderr: message,
|
||||
record: (level, thread, text),
|
||||
} => {
|
||||
if let Some(message) = message {
|
||||
let _ = writeln!(stderr.lock(), "{message}");
|
||||
@@ -319,6 +323,12 @@ impl Writer {
|
||||
text.push('\n');
|
||||
let _ = latest.file.write_all(text.as_bytes());
|
||||
}
|
||||
// After the file has it. A callback that panics is not called again: the
|
||||
// panic's own log line would reach it next.
|
||||
if let Some(hook) = LINE_HOOK.get().filter(|_| hook_alive) {
|
||||
let call = || hook(level.as_str(), &thread, &text, &lines.join("\n"));
|
||||
hook_alive = std::panic::catch_unwind(call).is_ok();
|
||||
}
|
||||
}
|
||||
Out::Flush(done) => {
|
||||
if let Some(latest) = &mut self.file {
|
||||
@@ -395,6 +405,18 @@ impl Floods {
|
||||
|
||||
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
|
||||
|
||||
/// What [`set_line_hook`] installed: level (`INFO`, `WARN`, `ERROR`), thread, message, and the
|
||||
/// lines as `latest.log` got them.
|
||||
type LineHook = fn(&str, &str, &str, &str);
|
||||
static LINE_HOOK: std::sync::OnceLock<LineHook> = std::sync::OnceLock::new();
|
||||
|
||||
/// Calls `hook` on the log's writer thread for each message written to the log, once the file
|
||||
/// has it, so the thread that logged never waits on it. Once per process; a second call keeps
|
||||
/// the first.
|
||||
pub fn set_line_hook(hook: LineHook) {
|
||||
let _ = LINE_HOOK.set(hook);
|
||||
}
|
||||
|
||||
fn ours(target: &str) -> bool {
|
||||
target.starts_with("horizon")
|
||||
}
|
||||
@@ -490,6 +512,7 @@ impl Logger {
|
||||
date: now.date_naive(),
|
||||
lines,
|
||||
stderr,
|
||||
record: (level, thread.to_owned(), message.to_owned()),
|
||||
};
|
||||
if let Err(mpsc::SendError(Out::Write {
|
||||
stderr: Some(message),
|
||||
@@ -502,12 +525,25 @@ impl Logger {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `latest.log` this run writes, once `init` opened it.
|
||||
static LATEST_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// The `latest.log` this run writes, absolute; None before `init` and when it logs to the
|
||||
/// terminal only.
|
||||
pub fn latest_path() -> Option<PathBuf> {
|
||||
LATEST_PATH.get().cloned()
|
||||
}
|
||||
|
||||
/// Starts the log in `home/logs`. The main thread shows as `main_thread` in it. Messages are
|
||||
/// still printed when the file cannot be opened.
|
||||
pub fn init(home: &Path, main_thread: &'static str) {
|
||||
let dir = logs_dir(home);
|
||||
let file = match open_latest(&dir) {
|
||||
Ok(Some(latest)) => Some(latest),
|
||||
Ok(Some(latest)) => {
|
||||
let path = dir.join("latest.log");
|
||||
let _ = LATEST_PATH.set(std::path::absolute(&path).unwrap_or(path));
|
||||
Some(latest)
|
||||
}
|
||||
Ok(None) => {
|
||||
eprintln!(
|
||||
"Horizon: another game is writing {}; this one logs to the terminal only",
|
||||
|
||||
@@ -63,6 +63,11 @@ struct Globals {
|
||||
// which then samples exactly as before.
|
||||
override SPRITE_ARRAYS: u32 = 1u;
|
||||
override SPRITE_SPLIT: u32 = 2048u;
|
||||
// Whether the solid shaders carry HDR output's lighting (a pipeline constant): the
|
||||
// renderer builds its solid terrain without it for the frames HDR is off, where
|
||||
// `hdr_light.z` is 0, since even a branch never taken costs Apple GPUs ~15% of a
|
||||
// solid pixel's time (gfx.rs's `SolidPipeline`).
|
||||
override HDR_LIGHTING: bool = true;
|
||||
// A sprite sampled with the given gradients, from whichever array holds its layer.
|
||||
fn sprite_grad(uv: vec2<f32>, layer: u32, dx: vec2<f32>, dy: vec2<f32>) -> vec4<f32> {
|
||||
let l = i32(layer % SPRITE_SPLIT);
|
||||
@@ -620,7 +625,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
}
|
||||
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
|
||||
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
if globals.hdr_light.z != 0.0 {
|
||||
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
}
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
@@ -637,7 +642,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
fn solid_color(in: TerrainOut) -> vec4<f32> {
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
if globals.hdr_light.z != 0.0 {
|
||||
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
}
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
@@ -665,7 +670,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
// fully fogged mountain would stand out from the fog behind it.
|
||||
// Without HDR output nothing reads the alpha, so its lighting is not worked out.
|
||||
var emissive = 1.0;
|
||||
if globals.hdr_light.z != 0.0 {
|
||||
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
|
||||
@@ -718,6 +723,33 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
|
||||
// Native still-fluid boxes use block sprites and lightmap without terrain AO/emission
|
||||
// packing or entity-model diffuse shading. The absolute sprite layer occupies glint UV x.
|
||||
struct NativeFluidOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) @interpolate(flat) sprite: u32,
|
||||
@location(3) spherical: f32,
|
||||
@location(4) cylindrical: f32,
|
||||
}
|
||||
@vertex fn vs_native_fluid(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(6) sprite: vec2<f32>) -> NativeFluidOut {
|
||||
let block = max(f32(light.y), dynamic_block_light(position));
|
||||
var lit = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
|
||||
if globals.colored_count.x > 0u {
|
||||
lit = vec4(min(lit.rgb + color.rgb * native_colored_light(position), vec3(1.0)), lit.a);
|
||||
}
|
||||
return NativeFluidOut(globals.view_proj * vec4(position, 1.0), uv, lit, u32(sprite.x),
|
||||
length(position), max(length(position.xz), abs(position.y)));
|
||||
}
|
||||
@fragment fn fs_native_fluid(in: NativeFluidOut) -> @location(0) vec4<f32> {
|
||||
// The source translucent shader has no alpha discard; transparent texels write depth too.
|
||||
let color = sprite_grad(in.uv, in.sprite, dpdx(in.uv), dpdy(in.uv)) * in.color;
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
|
||||
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
|
||||
// the sky colour with sky fog (sky.fsh: fog 0..FogSkyEnd on the spherical
|
||||
// distance, a step at FogSkyEnd on the cylindrical one), evaluated per pixel
|
||||
|
||||
@@ -184,6 +184,14 @@ impl Weather {
|
||||
self.rain_level.1 =
|
||||
(self.rain_level.1 + if self.raining { 0.01 } else { -0.01 }).clamp(0.0, 1.0);
|
||||
}
|
||||
/// The levels at their targets at once (rain 1 while it rains, else 0, and thunder the
|
||||
/// same), both halves, so the frame shows them at any partial tick: a frozen world under
|
||||
/// `HORIZON_STILL`, which would otherwise have to run about a hundred ticks to fade them.
|
||||
pub fn settle_levels(&mut self) {
|
||||
let target = |on: bool| if on { 1.0 } else { 0.0 };
|
||||
self.rain_level = (target(self.raining), target(self.raining));
|
||||
self.thunder_level = (target(self.thundering), target(self.thundering));
|
||||
}
|
||||
/// Level.getRainLevel / getThunderLevel (thunder scales with rain).
|
||||
pub fn levels(&self, partial: f32) -> (f32, f32) {
|
||||
let rain = self.rain_level.0 + (self.rain_level.1 - self.rain_level.0) * partial;
|
||||
@@ -562,11 +570,31 @@ impl Default for HealthRules {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DirtySections, World};
|
||||
use super::{DirtySections, Weather, World};
|
||||
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `/weather thunder` then `/weather clear` in a frozen world: the levels sit at their
|
||||
/// targets at every partial tick, where a tick of fading would have moved them 0.01.
|
||||
#[test]
|
||||
fn settled_weather_shows_its_targets_at_any_partial_tick() {
|
||||
let mut weather = Weather {
|
||||
raining: true,
|
||||
thundering: true,
|
||||
rain_level: (0.25, 0.26),
|
||||
..Weather::default()
|
||||
};
|
||||
weather.settle_levels();
|
||||
for partial in [0.0, 0.37, 1.0] {
|
||||
assert_eq!(weather.levels(partial), (1.0, 1.0), "{partial}");
|
||||
}
|
||||
weather.raining = false;
|
||||
weather.thundering = false;
|
||||
weather.settle_levels();
|
||||
assert_eq!(weather.levels(0.5), (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// Every block write marks the same section set the 27-cell loop did, but
|
||||
/// only the distinct keys: one in the interior, a neighbour section on a
|
||||
/// section face, a neighbour column on a chunk border and the diagonal at
|
||||
|
||||
@@ -892,5 +892,9 @@
|
||||
"Editing: %s": "Editing: %s",
|
||||
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
|
||||
"This button also does: %s": "This button also does: %s",
|
||||
"horizon.pad.notice.connected.press": "Press to use it"
|
||||
"horizon.pad.notice.connected.press": "Press to use it",
|
||||
"horizon.recipe_transfer.missing": "Missing Items",
|
||||
"horizon.recipe_transfer.no_room": "Inventory full",
|
||||
"horizon.recipe_transfer.unavailable": "Recipe does not fit this menu",
|
||||
"horizon.recipe_transfer.unsupported": "Server requires a known recipe"
|
||||
}
|
||||
@@ -50,6 +50,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
&m,
|
||||
&c,
|
||||
case["initial"].as_str().unwrap_or(""),
|
||||
false,
|
||||
history.len(),
|
||||
w,
|
||||
h,
|
||||
|
||||
@@ -8,7 +8,8 @@ use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
items::Items,
|
||||
mobs::{
|
||||
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
|
||||
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES,
|
||||
species::can_be_a_baby, sulfur_cube,
|
||||
},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
@@ -111,7 +112,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for slot in drops::SLOTS {
|
||||
drops::clear_slot(&mut m, slot);
|
||||
}
|
||||
if get("baby", "0") == "1" && drops::can_be_baby(m.species()) {
|
||||
if get("baby", "0") == "1" && can_be_a_baby(m.species()) {
|
||||
m.extra.age = -24000;
|
||||
}
|
||||
if get("fire", "0") == "1" {
|
||||
|
||||
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
}
|
||||
let partial = float(c, "partial", 0.0);
|
||||
let bg = background(c);
|
||||
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
|
||||
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
|
||||
out.extend(t.title(w, h, partial, &atlas.font, bg));
|
||||
out
|
||||
}
|
||||
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
}
|
||||
let mode = c["gameMode"].as_str().unwrap_or("survival");
|
||||
let can_hurt = mode == "survival" || mode == "adventure";
|
||||
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
|
||||
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
|
||||
}
|
||||
|
||||
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
|
||||
@@ -146,6 +146,16 @@ fn frame(v: &Value) -> Frame {
|
||||
}
|
||||
}
|
||||
impl Tree {
|
||||
/// `PlayerAdvancements.checkForAutomaticTriggers`: the advancements that
|
||||
/// have no criteria at all, so nothing can ever grant them a criterion and
|
||||
/// vanilla hands them over (and grants their rewards) as the player's
|
||||
/// progress loads.
|
||||
pub fn automatic(&self) -> Vec<usize> {
|
||||
(0..self.list.len())
|
||||
.filter(|&i| self.list[i].criteria.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`Tree::load`], shared while anyone holds it: a server's advancement
|
||||
/// packets (`ServerAdvancements`) and its game's criteria read the same
|
||||
/// files, and each kept its own copy (7 MB). A world whose data packs bring
|
||||
@@ -2677,6 +2687,28 @@ mod tests {
|
||||
p.grant("minecraft:t", "b", "now");
|
||||
assert!(p.done(&a));
|
||||
}
|
||||
/// `AdvancementRequirements.test` answers false with no requirement groups, so an advancement
|
||||
/// without criteria is never done by `isDone`; `PlayerAdvancements.checkForAutomaticTriggers`
|
||||
/// still grants its rewards as progress loads, and `Tree::automatic` is the list it walks.
|
||||
#[test]
|
||||
fn an_advancement_without_criteria_is_not_done_but_granted_automatically() {
|
||||
let empty = parse("minecraft:free", &serde_json::json!({"criteria": {}})).unwrap();
|
||||
assert!(!Progress::default().done(&empty), "no groups: not done");
|
||||
let waiting = parse(
|
||||
"minecraft:waits",
|
||||
&serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!Progress::default().done(&waiting));
|
||||
let tree = tree_of(&[
|
||||
(
|
||||
"minecraft:waits",
|
||||
serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
|
||||
),
|
||||
("minecraft:free", serde_json::json!({"criteria": {}})),
|
||||
]);
|
||||
assert_eq!(tree.automatic(), vec![1]);
|
||||
}
|
||||
#[test]
|
||||
fn layout_spreads_children_like_tree_node_position() {
|
||||
let mut tree = Tree::default();
|
||||
|
||||
@@ -57,6 +57,21 @@ pub fn emit(event: Event<'_>) {
|
||||
dispatch(event, &mut 0.0);
|
||||
}
|
||||
|
||||
/// The game's native trample hook (`native_trample`), set once by the game: dimension, entity
|
||||
/// id, entity type, the farmland's position, the fall distance and cancel.
|
||||
pub static TRAMPLE: OnceLock<fn(&str, i32, &str, [i32; 3], f64, &mut bool)> = OnceLock::new();
|
||||
|
||||
/// Whether the mods let `entity` (of type `kind`) trample the farmland at `position`.
|
||||
pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f64) -> bool {
|
||||
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
|
||||
return true;
|
||||
};
|
||||
let mut cancel = false;
|
||||
let at = [position.x, position.y, position.z];
|
||||
hook(dimension, entity, kind, at, distance, &mut cancel);
|
||||
!cancel
|
||||
}
|
||||
|
||||
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
|
||||
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
|
||||
dispatch(Event::FallOn(contact, distance), &mut damage);
|
||||
|
||||
@@ -42,6 +42,8 @@ pub struct Interaction {
|
||||
pub sound: Option<&'static str>,
|
||||
/// The custom statistic the player earns (`use_cauldron`, ...).
|
||||
pub stat: Option<&'static str>,
|
||||
/// `lowerFillLevel` posts BLOCK_CHANGE with the new state.
|
||||
pub lower_fill_level: bool,
|
||||
}
|
||||
|
||||
fn state(name: &str, level: Option<u8>) -> BlockState {
|
||||
@@ -60,6 +62,7 @@ fn pass() -> Interaction {
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
lower_fill_level: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,9 +129,20 @@ fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -
|
||||
block: Some(block),
|
||||
sound,
|
||||
stat: Some(stat),
|
||||
lower_fill_level: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn changed_lowered(
|
||||
cauldron: &BlockState,
|
||||
sound: Option<&'static str>,
|
||||
stat: &'static str,
|
||||
) -> Interaction {
|
||||
let mut result = changed(lowered(cauldron), sound, stat);
|
||||
result.lower_fill_level = true;
|
||||
result
|
||||
}
|
||||
|
||||
const COLORS: [&str; 16] = [
|
||||
"white",
|
||||
"orange",
|
||||
@@ -183,7 +197,7 @@ pub fn interact(
|
||||
stack.data_mut().dyed_color = None;
|
||||
stack.tidy();
|
||||
}
|
||||
return changed(lowered(cauldron), None, "clean_armor");
|
||||
return changed_lowered(cauldron, None, "clean_armor");
|
||||
}
|
||||
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
|
||||
if !can_fill {
|
||||
@@ -208,6 +222,7 @@ pub fn interact(
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
lower_fill_level: false,
|
||||
}
|
||||
} else {
|
||||
empty_bucket(
|
||||
@@ -231,6 +246,7 @@ pub fn interact(
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
lower_fill_level: false,
|
||||
}
|
||||
} else {
|
||||
empty_bucket(
|
||||
@@ -266,11 +282,7 @@ pub fn interact(
|
||||
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
|
||||
true,
|
||||
);
|
||||
changed(
|
||||
lowered(cauldron),
|
||||
Some("minecraft:item.bottle.fill"),
|
||||
"use_cauldron",
|
||||
)
|
||||
changed_lowered(cauldron, Some("minecraft:item.bottle.fill"), "use_cauldron")
|
||||
}
|
||||
("water", "minecraft:potion") => {
|
||||
if level == 3 || !water_potion {
|
||||
@@ -298,7 +310,7 @@ pub fn interact(
|
||||
cleaned.data_mut().banner_patterns.pop();
|
||||
cleaned.tidy();
|
||||
filled(player, cleaned, false);
|
||||
changed(lowered(cauldron), None, "clean_banner")
|
||||
changed_lowered(cauldron, None, "clean_banner")
|
||||
}
|
||||
("water", name)
|
||||
if name
|
||||
@@ -307,7 +319,7 @@ pub fn interact(
|
||||
.is_some_and(|c| COLORS.contains(&c)) =>
|
||||
{
|
||||
filled(player, held.transmute("minecraft:shulker_box", 1), false);
|
||||
changed(lowered(cauldron), None, "clean_shulker_box")
|
||||
changed_lowered(cauldron, None, "clean_shulker_box")
|
||||
}
|
||||
("lava", "minecraft:bucket") => fill_bucket(
|
||||
player,
|
||||
|
||||
@@ -13,17 +13,18 @@ pub use horizon_client_render::{
|
||||
block_entity_anim, block_entity_render, block_sprites, block_states, bluemap, bluemap_detect,
|
||||
border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets,
|
||||
cit_glint, client_resources, clouds, collisions, command_block, containers, controller_ui,
|
||||
crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx,
|
||||
graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
|
||||
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
|
||||
menu_blur, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
|
||||
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
|
||||
pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw, screen_effects,
|
||||
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
crash, custom_sky, datapack, dynamic_light, effects, environment, extras, fast_hash,
|
||||
fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_browser, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles,
|
||||
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
|
||||
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
|
||||
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
|
||||
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
|
||||
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
|
||||
world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
|
||||
@@ -289,6 +289,8 @@ impl ServerTickRateManager {
|
||||
/// Ticks one sprint frame may run (rendering carries on between frames).
|
||||
const SPRINT_PER_FRAME: i64 = 40;
|
||||
const SAMPLES: usize = 100;
|
||||
/// The windows of [`FixedStep::tps`], in seconds: one, five and fifteen minutes.
|
||||
const TPS_WINDOWS: [f64; 3] = [60.0, 300.0, 900.0];
|
||||
|
||||
/// A step command's broadcast value and the ticks still owed to a new client.
|
||||
/// `ServerTickRateManager.updateStepTicks` sends the former immediately;
|
||||
@@ -316,6 +318,10 @@ pub struct FixedStep {
|
||||
tick_times: [i64; SAMPLES],
|
||||
aggregated: i64,
|
||||
tick_count: i64,
|
||||
/// The ticks a second over the last [`TPS_WINDOWS`], decayed once a second ([`Self::tps`]).
|
||||
tps: [f64; 3],
|
||||
/// When the second being counted started (at a recorded tick's end) and the ticks since.
|
||||
tps_second: Option<(std::time::Instant, u32)>,
|
||||
/// A server's client: its level's tick rate only slows the timer down
|
||||
/// (`Minecraft.getTickTargetMillis`), since the server runs the ticks.
|
||||
client: bool,
|
||||
@@ -349,6 +355,8 @@ impl FixedStep {
|
||||
tick_times: [0; SAMPLES],
|
||||
aggregated: 0,
|
||||
tick_count: 0,
|
||||
tps: [20.0; 3],
|
||||
tps_second: None,
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
@@ -530,6 +538,41 @@ impl FixedStep {
|
||||
let end = self.now_ns() + took;
|
||||
self.manager.end_tick_work(end);
|
||||
}
|
||||
self.count_tick(std::time::Instant::now());
|
||||
}
|
||||
/// A recorded tick ended at `now`: once a second has passed since the second being
|
||||
/// counted started, its ticks a second join each average of [`Self::tps`], weighted as a
|
||||
/// load average is (`exp(-elapsed / window)` kept of the old value).
|
||||
fn count_tick(&mut self, now: std::time::Instant) {
|
||||
let Some((start, ticks)) = &mut self.tps_second else {
|
||||
self.tps_second = Some((now, 0));
|
||||
return;
|
||||
};
|
||||
*ticks += 1;
|
||||
let elapsed = now.saturating_duration_since(*start).as_secs_f64();
|
||||
if elapsed < 1.0 {
|
||||
return;
|
||||
}
|
||||
let rate = f64::from(*ticks) / elapsed;
|
||||
for (average, window) in self.tps.iter_mut().zip(TPS_WINDOWS) {
|
||||
let kept = (-elapsed / window).exp();
|
||||
*average = *average * kept + rate * (1.0 - kept);
|
||||
}
|
||||
self.tps_second = Some((now, 0));
|
||||
}
|
||||
/// The ticks a second the server kept over the last one, five and fifteen minutes (what
|
||||
/// Bukkit's `Server.getTPS` reports; vanilla keeps no such average): measured between
|
||||
/// recorded tick ends, so paused passes count where they are recorded. Starts at 20.
|
||||
pub fn tps(&self) -> [f64; 3] {
|
||||
self.tps
|
||||
}
|
||||
/// `getTickTimesNanos` as vanilla lays it out: the tick numbered `n` (from 1, as
|
||||
/// `MinecraftServer.tickCount` after its increment) at index `n % 100`, so the
|
||||
/// newest is at the recorded ticks' count `% 100`; 0 where no tick was recorded yet.
|
||||
pub fn tick_times_nanos(&self) -> Vec<u64> {
|
||||
(0..SAMPLES)
|
||||
.map(|i| self.tick_times[(i + SAMPLES - 1) % SAMPLES].max(0) as u64)
|
||||
.collect()
|
||||
}
|
||||
/// `getAverageTickTimeNanos`.
|
||||
pub fn average_nanos(&self) -> u64 {
|
||||
@@ -547,6 +590,51 @@ impl FixedStep {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Twenty ticks a second keep 20; ten a second pull the one-minute average down
|
||||
/// fastest; a second not yet over changes nothing.
|
||||
#[test]
|
||||
fn tps_averages_follow_the_ticks_a_second() {
|
||||
let mut clock = FixedStep::new(false);
|
||||
let t0 = std::time::Instant::now();
|
||||
let ms = std::time::Duration::from_millis;
|
||||
for i in 0..=40 {
|
||||
clock.count_tick(t0 + ms(50 * i));
|
||||
}
|
||||
assert_eq!(
|
||||
clock.tps().map(|v| (v * 1000.0).round() / 1000.0),
|
||||
[20.0; 3]
|
||||
);
|
||||
let slow = t0 + ms(2000);
|
||||
for i in 1..=10 {
|
||||
clock.count_tick(slow + ms(100 * i));
|
||||
}
|
||||
let [one, five, fifteen] = clock.tps();
|
||||
assert!(
|
||||
one < five && five < fifteen && fifteen < 20.0,
|
||||
"{:?}",
|
||||
clock.tps()
|
||||
);
|
||||
assert!((one - (20.0 - 10.0 * (1.0 - (-1.0f64 / 60.0).exp()))).abs() < 1e-9);
|
||||
clock.count_tick(slow + ms(1050));
|
||||
assert_eq!(clock.tps(), [one, five, fifteen]);
|
||||
}
|
||||
|
||||
/// The tick times sit where vanilla's array has them: tick n at n % 100.
|
||||
#[test]
|
||||
fn tick_times_are_laid_out_as_vanillas() {
|
||||
let mut clock = FixedStep::new(false);
|
||||
assert_eq!(clock.tick_times_nanos(), vec![0; SAMPLES]);
|
||||
for n in 1..=150u64 {
|
||||
clock.record(std::time::Duration::from_nanos(n));
|
||||
}
|
||||
let times = clock.tick_times_nanos();
|
||||
assert_eq!(times.len(), SAMPLES);
|
||||
assert_eq!(times[50], 150, "the newest, tick 150");
|
||||
assert_eq!(times[51], 51, "tick 51, overwritten by none yet");
|
||||
assert_eq!(times[0], 100);
|
||||
assert_eq!(times[1], 101);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_carries_fractions_and_caps_nothing_itself() {
|
||||
let mut t = Timer::new(20.0, 0);
|
||||
|
||||
@@ -666,7 +666,7 @@ impl ItemEntity {
|
||||
wanted
|
||||
} else {
|
||||
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still), and
|
||||
// too (a boat, a shulker, a happy ghast held still; not a minecart), and
|
||||
// so does the world border (`Entity.collectColliders`).
|
||||
crate::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
@@ -1313,6 +1313,35 @@ impl ExperienceOrb {
|
||||
border: Option<&BorderGeometry>,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
) -> bool {
|
||||
self.tick_among(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
player,
|
||||
server,
|
||||
next_float,
|
||||
border,
|
||||
fluids,
|
||||
on_sound,
|
||||
&[],
|
||||
)
|
||||
}
|
||||
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
|
||||
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tick_among(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
player: Option<[f64; 3]>,
|
||||
server: bool,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
border: Option<&BorderGeometry>,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
entities: &[Aabb],
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -1374,10 +1403,11 @@ impl ExperienceOrb {
|
||||
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
|
||||
// push and before the move.
|
||||
let wanted = self.velocity;
|
||||
// Entity.collide: the world border's walls stop the orb too.
|
||||
// Entity.collide: the world border's walls and the entities that
|
||||
// `canBeCollidedWith` it (a boat, a shulker) stop the orb too.
|
||||
let border = border.map(|b| (b, [self.position.x, self.position.z]));
|
||||
let moved = scene
|
||||
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
|
||||
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
|
||||
@@ -105,6 +105,16 @@ impl Base<'_> {
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// Preserve Entity data without flags owned by Mob's save writer.
|
||||
/// Only the dedicated boat, item and armor-stand writers use this path.
|
||||
fn non_mob_tag(&self, raw: Option<&Tag>) -> Tag {
|
||||
let mut tag = self.tag(raw);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"NoAI".into());
|
||||
map.remove(&"PersistenceRequired".into());
|
||||
}
|
||||
tag
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.saveWithoutId`'s common fields for entities Horizon keeps as raw
|
||||
@@ -128,8 +138,8 @@ pub fn fill_common_defaults(tag: &mut Tag) {
|
||||
|
||||
/// The mobs that save `Age` and `AgeLocked`.
|
||||
fn saves_age(s: crate::mobs::Species) -> bool {
|
||||
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
|
||||
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
|
||||
use crate::mobs::{Species, finalize::ageable, species::can_be_a_baby};
|
||||
s == Species::Tadpole || (ageable(s) && can_be_a_baby(s))
|
||||
}
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
@@ -633,15 +643,11 @@ pub fn mob_to_nbt(
|
||||
if species != crate::mobs::Species::Tadpole {
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
}
|
||||
} else if matches!(
|
||||
species,
|
||||
crate::mobs::Species::Parrot
|
||||
| crate::mobs::Species::Slime
|
||||
| crate::mobs::Species::MagmaCube
|
||||
| crate::mobs::Species::ZombieNautilus
|
||||
) {
|
||||
} else if crate::mobs::finalize::ageable(species) {
|
||||
// AgeableMob.addAdditionalSaveData writes nothing when canBeABaby is
|
||||
// false: drop the ignored values a raw input compound carried.
|
||||
// false — the frog, the parrot, the zombie nautilus, the camel husk,
|
||||
// the slime and the magma cube: drop the ignored values a raw input
|
||||
// compound carried rather than writing them back out.
|
||||
for key in ["Age", "ForcedAge", "AgeLocked"] {
|
||||
map.remove(&key.into());
|
||||
}
|
||||
@@ -2356,7 +2362,7 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
on_ground: e.on_ground,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
let mut tag = base.non_mob_tag(raw);
|
||||
for (key, value) in [
|
||||
("Item", item_to_nbt(&e.stack)),
|
||||
("Health", Tag::Short(e.health as i16)),
|
||||
@@ -3428,7 +3434,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
on_ground: a.on_ground,
|
||||
fall_distance: a.enchantment_fall_distance,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
let mut tag = base.non_mob_tag(raw);
|
||||
let equipment: Vec<(&str, Tag)> = ArmorStand::SLOTS
|
||||
.iter()
|
||||
.zip(&a.equipment)
|
||||
@@ -3582,7 +3588,7 @@ pub fn boat_to_nbt(
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw);
|
||||
.non_mob_tag(raw);
|
||||
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
|
||||
map.insert("Items".into(), crate::save::encode_items(items));
|
||||
}
|
||||
@@ -4038,11 +4044,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubes_omit_supplied_age_and_pigs_keep_modeled_age() {
|
||||
fn an_ageable_mob_that_cannot_be_a_baby_saves_no_age() {
|
||||
use crate::mobs::Species;
|
||||
// AgeableMob.addAdditionalSaveData writes Age only when canBeABaby is
|
||||
// true, so every ageable class that overrides it to false writes none
|
||||
// of it: a frog and a camel husk keep no raw `Age` either.
|
||||
for (species, id) in [
|
||||
(Species::Slime, "minecraft:slime"),
|
||||
(Species::MagmaCube, "minecraft:magma_cube"),
|
||||
(Species::Frog, "minecraft:frog"),
|
||||
(Species::CamelHusk, "minecraft:camel_husk"),
|
||||
(Species::Pig, "minecraft:pig"),
|
||||
] {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
@@ -4934,6 +4945,95 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
|
||||
let uuid = [104, 0, 0, 1];
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let def = Arc::make_mut(&mut kind);
|
||||
def.id = "minecraft:pig".into();
|
||||
def.species = crate::mobs::Species::Pig;
|
||||
let types = [("minecraft:pig".into(), kind)].into();
|
||||
for input in [None, Some(0), Some(1)] {
|
||||
for id in [
|
||||
"minecraft:oak_boat",
|
||||
"minecraft:oak_chest_boat",
|
||||
"minecraft:armor_stand",
|
||||
"minecraft:item",
|
||||
"minecraft:pig",
|
||||
] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[4.5, 200.0, 4.5])),
|
||||
("Motion", doubles(&[0.0, 0.0, 0.0])),
|
||||
("Silent", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("Invulnerable", Tag::Byte(1)),
|
||||
("PortalCooldown", Tag::Int(17)),
|
||||
("Tags", Tag::List(vec![string("audit")])),
|
||||
("Unrelated", string("keep")),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new("minecraft:stone", 1)),
|
||||
),
|
||||
]);
|
||||
if let Some(value) = input {
|
||||
put(&mut raw, "NoAI", Tag::Byte(value));
|
||||
put(&mut raw, "PersistenceRequired", Tag::Byte(value));
|
||||
}
|
||||
let stand = armor_stand_from_nbt(&raw, 1).unwrap();
|
||||
let item = item_entity_from_nbt(&raw, 1, 0.0).unwrap();
|
||||
let boat = boat_from_nbt(&raw).unwrap();
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = match id {
|
||||
"minecraft:armor_stand" => armor_stand_to_nbt(&stand, uuid, source),
|
||||
"minecraft:item" => item_entity_to_nbt(&item, uuid, source),
|
||||
"minecraft:pig" => {
|
||||
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
// The writer must use live state, even opposite stale raw values.
|
||||
mob.no_ai = input != Some(1);
|
||||
mob.persistent = input != Some(1);
|
||||
mob_to_nbt(&mob, uuid, source, None, |_| None)
|
||||
}
|
||||
_ => boat_to_nbt(
|
||||
id,
|
||||
boat.0,
|
||||
boat.1,
|
||||
[boat.2, 0.0],
|
||||
uuid,
|
||||
source,
|
||||
(id == "minecraft:oak_chest_boat").then_some(&[]),
|
||||
),
|
||||
};
|
||||
if id == "minecraft:pig" {
|
||||
let live = input != Some(1);
|
||||
assert_eq!(saved.get("NoAI"), live.then_some(&Tag::Byte(1)));
|
||||
assert_eq!(
|
||||
saved.get("PersistenceRequired"),
|
||||
Some(&Tag::Byte(i8::from(live)))
|
||||
);
|
||||
} else {
|
||||
assert!(saved.get("NoAI").is_none(), "{id} {input:?}");
|
||||
assert!(saved.get("PersistenceRequired").is_none(), "{id} {input:?}");
|
||||
}
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
|
||||
assert_eq!(saved.get("Pos"), raw.get("Pos"));
|
||||
if source.is_some() {
|
||||
for key in [
|
||||
"Silent",
|
||||
"NoGravity",
|
||||
"Invulnerable",
|
||||
"PortalCooldown",
|
||||
"Tags",
|
||||
"Unrelated",
|
||||
] {
|
||||
assert_eq!(saved.get(key), raw.get(key), "{id} {key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animal_integer_loads_save_modeled_values_without_raw_source() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
|
||||
@@ -46,6 +46,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
}
|
||||
(logging::game_home(), "client", "Render thread")
|
||||
};
|
||||
// Before the log opens, so a mod's injection hears its first lines.
|
||||
logging::set_line_hook(crate::native_client_hooks::native_log_line);
|
||||
if !help {
|
||||
logging::init(
|
||||
logging::player_dir_override().unwrap_or(home.as_path()),
|
||||
@@ -98,6 +100,35 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
{
|
||||
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
if horizon_world::worldgen::native_generate::install(
|
||||
horizon_world::worldgen::native_generate::NativeGenerateHooks {
|
||||
chunk_generate: |dimension, chunk, seed, flat, chunk_out, generate| {
|
||||
crate::mod_api::dispatch_chunk_generate(
|
||||
crate::mod_api::mods(),
|
||||
dimension,
|
||||
chunk,
|
||||
seed,
|
||||
flat,
|
||||
chunk_out,
|
||||
generate,
|
||||
);
|
||||
},
|
||||
lod_column: |dimension, column, seed, flat, column_out| {
|
||||
crate::mod_api::dispatch_lod_column(
|
||||
crate::mod_api::mods(),
|
||||
dimension,
|
||||
column,
|
||||
seed,
|
||||
flat,
|
||||
column_out,
|
||||
);
|
||||
},
|
||||
},
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Native generation hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
|
||||
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
|
||||
crate::mod_api::dispatch_menu_slot_rules(
|
||||
@@ -168,6 +199,23 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
register_goals: |mob| {
|
||||
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
|
||||
},
|
||||
player_pre_login: |player, name, address, defer| {
|
||||
crate::mod_api::dispatch_player_pre_login(
|
||||
crate::mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
address,
|
||||
defer,
|
||||
);
|
||||
},
|
||||
player_profile: |player, name, properties| {
|
||||
crate::mod_api::dispatch_player_profile(
|
||||
crate::mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
properties,
|
||||
);
|
||||
},
|
||||
ingredient: |kind, data, items| {
|
||||
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
|
||||
},
|
||||
@@ -180,6 +228,19 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
accept,
|
||||
);
|
||||
},
|
||||
log_line: |level, thread, message, line| {
|
||||
crate::mod_api::dispatch_log_line(crate::mod_api::mods(), level, thread, message, line);
|
||||
},
|
||||
handshake: |address, host, intent, cancel, reason| {
|
||||
crate::mod_api::dispatch_handshake(
|
||||
crate::mod_api::mods(),
|
||||
address,
|
||||
host,
|
||||
intent,
|
||||
reason,
|
||||
cancel,
|
||||
);
|
||||
},
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
|
||||
@@ -78,9 +78,6 @@ impl FurnaceCooking for Furnace {
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
@@ -250,6 +247,69 @@ mod tests {
|
||||
(items, recipes, providers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_totals_survive_until_actual_completion() {
|
||||
let (items, recipes, providers) = data("loaded-total");
|
||||
assert_eq!(
|
||||
recipes
|
||||
.cook(
|
||||
FurnaceKind::Furnace.recipe_type(),
|
||||
&ItemStack::new("test:ore", 3)
|
||||
)
|
||||
.unwrap()
|
||||
.time,
|
||||
200
|
||||
);
|
||||
// Fresh Java's five-case Game fixture independently admits the loaded
|
||||
// zero (including a missing saved field), five and normal totals.
|
||||
for (total, timer, completion) in [(0, 0, 1), (5, 0, 5), (200, 0, 200), (5, 1, 4)] {
|
||||
let mut f = Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [Some(ItemStack::new("test:ore", 3)), None, None],
|
||||
lit_remaining: 20,
|
||||
lit_total: 20,
|
||||
cooking_timer: timer,
|
||||
cooking_total: total,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
for elapsed in 1..=6 {
|
||||
let mut drops = Vec::new();
|
||||
assert!(!f.idle());
|
||||
assert_eq!(f.tick(&recipes, &items, &providers, &mut drops), None);
|
||||
assert!(drops.is_empty());
|
||||
assert_eq!((f.lit_remaining, f.lit_total), (20 - elapsed, 20));
|
||||
assert!(f.items[1].is_none());
|
||||
let complete = elapsed >= completion;
|
||||
assert_eq!(
|
||||
f.items[0].as_ref().unwrap().count,
|
||||
if complete { 2 } else { 3 },
|
||||
"loaded {total}/{timer}, tick {elapsed}"
|
||||
);
|
||||
assert_eq!(
|
||||
f.items[2].as_ref().map(|s| (&*s.item, s.count)),
|
||||
if complete {
|
||||
Some(("test:ingot", 1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
);
|
||||
assert_eq!(f.cooking_total, if complete { 200 } else { total });
|
||||
assert_eq!(
|
||||
f.cooking_timer,
|
||||
if complete {
|
||||
elapsed - completion
|
||||
} else {
|
||||
timer + elapsed
|
||||
}
|
||||
);
|
||||
}
|
||||
// A changed input still goes through setItem's independent reset.
|
||||
f.set_input(&recipes, Some(ItemStack::new("test:ingot", 1)));
|
||||
assert_eq!((f.cooking_timer, f.cooking_total), (0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooking_requires_output_components_and_capacity() {
|
||||
let (items, recipes, providers) = data("output-components");
|
||||
|
||||
@@ -104,6 +104,7 @@ mod creative_hotbar;
|
||||
mod creatures;
|
||||
mod crossbow;
|
||||
pub(crate) mod cushions;
|
||||
mod custom_sky;
|
||||
mod debug_keys;
|
||||
mod debug_lines;
|
||||
mod debug_options;
|
||||
@@ -223,8 +224,10 @@ mod native_biome_colors;
|
||||
pub mod native_block_entity_draw;
|
||||
mod native_block_sprites;
|
||||
mod native_bots;
|
||||
mod native_chunk_snapshot;
|
||||
mod native_chunk_tickets;
|
||||
mod native_claimed_items;
|
||||
mod native_client_entities;
|
||||
mod native_client_particles;
|
||||
mod native_client_particles_fixture;
|
||||
pub mod native_client_services;
|
||||
@@ -232,13 +235,16 @@ mod native_client_world;
|
||||
mod native_components;
|
||||
mod native_configs;
|
||||
mod native_criteria;
|
||||
mod native_damage;
|
||||
mod native_data_maps;
|
||||
mod native_diagnostics;
|
||||
mod native_entities;
|
||||
mod native_entity_data;
|
||||
mod native_entity_motion;
|
||||
mod native_entity_refs;
|
||||
mod native_explosions;
|
||||
mod native_falling;
|
||||
mod native_fluid_render;
|
||||
mod native_hooks;
|
||||
mod native_host_bridge;
|
||||
mod native_inventories;
|
||||
@@ -256,8 +262,15 @@ mod native_mobs;
|
||||
mod native_motion;
|
||||
mod native_mounts;
|
||||
mod native_net;
|
||||
mod native_npcs;
|
||||
mod native_packets;
|
||||
mod native_panel;
|
||||
mod native_panel_recipes;
|
||||
mod native_permissions;
|
||||
mod native_player_fx;
|
||||
mod native_player_reads;
|
||||
mod native_player_state;
|
||||
mod native_profiles;
|
||||
mod native_projectiles;
|
||||
mod native_quick_move;
|
||||
mod native_recipes;
|
||||
@@ -266,12 +279,17 @@ mod native_scene;
|
||||
mod native_server;
|
||||
mod native_shaders;
|
||||
mod native_structures;
|
||||
pub mod native_text_field;
|
||||
mod native_ticks;
|
||||
mod native_travel;
|
||||
mod native_view_distance;
|
||||
mod native_visuals;
|
||||
mod native_world_border;
|
||||
mod native_world_columns;
|
||||
pub mod native_world_draw;
|
||||
mod native_world_edit;
|
||||
mod native_world_read;
|
||||
mod native_world_seed;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
mod net_harness;
|
||||
@@ -459,6 +477,7 @@ use horizon_world::{
|
||||
pub use machines::native_fluids::NativeFluidTank;
|
||||
pub use maps::{NativeMapDecoration, NativeMapInfo};
|
||||
pub use native_block_entity_draw::BlockEntityDraw;
|
||||
pub use native_chunk_snapshot::NativeChunkSnapshot;
|
||||
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
|
||||
pub use native_client_world::{NativeBiomeId, NativeClientBiomes, NativeClientChunks};
|
||||
pub use native_components::NativeComponentError;
|
||||
@@ -467,8 +486,10 @@ pub use native_criteria::NativeCriterion;
|
||||
pub use native_data_maps::NativeDataMapType;
|
||||
pub use native_falling::NativeFalling;
|
||||
pub use native_hooks::{NativeClock, NativeItemEntity, NativePlayer, NativeShake};
|
||||
pub use native_inventories::NativeInventoryKind;
|
||||
pub use native_item_energy::ItemEnergyBuffer;
|
||||
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_player_state::NativeEffect;
|
||||
pub use native_reload_listeners::NativeReloadListener;
|
||||
pub use native_server::NativeExperienceOrb;
|
||||
pub use native_structures::{NativeStructureBlock, NativeStructureTemplate};
|
||||
@@ -1112,6 +1133,24 @@ impl EntityObstructionWorld for BuildingObstruction {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
/// Whether a block's successful use is `InteractionResult.SUCCESS_SERVER`,
|
||||
/// which a client does not predict: `useWithoutItem` of a bed, a light
|
||||
/// block, a charged respawn anchor or a sign. Every other block use the
|
||||
/// level answers with SUCCESS is `SUCCESS` (`SwingSource.PREDICTED`).
|
||||
fn server_only_block_use(
|
||||
block: &horizon_items::blocks::StateFacts,
|
||||
branch: horizon_world::level::UseBranch,
|
||||
) -> bool {
|
||||
branch == horizon_world::level::UseBranch::WithoutItem
|
||||
&& [
|
||||
"AbstractBedBlock",
|
||||
"LightBlock",
|
||||
"RespawnAnchorBlock",
|
||||
"SignBlock",
|
||||
]
|
||||
.iter()
|
||||
.any(|class| block.has_class(class))
|
||||
}
|
||||
/// An entity's box (`EntityDimensions.makeBoundingBox`): `width` wide and
|
||||
/// `height` tall, standing on `feet`.
|
||||
fn point_box(feet: [f64; 3], width: f64, height: f64) -> Aabb {
|
||||
@@ -1252,6 +1291,11 @@ impl LevelAccess for GameLevel<'_> {
|
||||
self.native_neighbor_changed(self.dimension, pos, state, changed, moved);
|
||||
}
|
||||
}
|
||||
fn redstone_change(&self, pos: BlockPos, old: i32, new: &mut i32) {
|
||||
if self.native_authoritative {
|
||||
self.native_block_redstone(self.dimension, pos, old, new);
|
||||
}
|
||||
}
|
||||
fn allow_spread(
|
||||
&self,
|
||||
kind: horizon_world::level::Spread,
|
||||
@@ -1574,6 +1618,9 @@ impl LevelAccess for GameLevel<'_> {
|
||||
/// the count a recount of the players with that container open finds, and
|
||||
/// the count it replaces. Only the block entities that keep a counter and
|
||||
/// a scheduled recheck answer; anything else is `None`.
|
||||
fn container_open(&self, pos: BlockPos) -> bool {
|
||||
self.open_containers.contains(&(pos.x, pos.y, pos.z))
|
||||
}
|
||||
fn recheck_container_openers(&mut self, pos: BlockPos) -> Option<(i32, i32)> {
|
||||
use horizon_world::level::classes::Class;
|
||||
let data = self.behaviours.data(self.world.block(pos.x, pos.y, pos.z));
|
||||
@@ -2240,6 +2287,8 @@ pub struct PackResources {
|
||||
clouds: Arc<(u32, u32, Vec<bool>)>,
|
||||
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
|
||||
forcefield: Arc<(u32, u32, Vec<u8>)>,
|
||||
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
|
||||
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
|
||||
}
|
||||
impl PackResources {
|
||||
/// Everything a pack overlay at `root` provides, over `assets` and `render`
|
||||
@@ -2315,6 +2364,7 @@ impl PackResources {
|
||||
names: Arc::new(Names::load(root)),
|
||||
clouds: Arc::new(load_clouds(root)),
|
||||
forcefield: Arc::new(load_forcefield(root)),
|
||||
custom_sky: Arc::new(horizon_client::custom_sky::CustomSky::load(root)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2874,6 +2924,9 @@ struct Game<'a> {
|
||||
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
swings_started: u32,
|
||||
/// Swings of uses whose client predicts them (`SwingSource.PREDICTED`):
|
||||
/// a server does not send these back to the player who swung.
|
||||
predicted_swings: u32,
|
||||
/// The first-person equip heights and the items the hands show (with
|
||||
/// the slot each came from).
|
||||
heights: horizon_client::first_person::HandHeights,
|
||||
@@ -2904,6 +2957,8 @@ struct Game<'a> {
|
||||
/// `ClientAvatarState`: walked distance and view bob.
|
||||
avatar: horizon_client::camera::AvatarBob,
|
||||
flash: u32,
|
||||
/// OptiFine custom sky layers' eased brightness (`custom_sky.rs`).
|
||||
custom_sky: custom_sky::CustomSkyView,
|
||||
/// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound.
|
||||
rain_sound_time: i32,
|
||||
/// Bolts that struck here and sky flashes they made, in total (`status`).
|
||||
@@ -2966,6 +3021,8 @@ struct Game<'a> {
|
||||
map_frame_model: frames::FrameModel,
|
||||
glow_map_frame_model: frames::FrameModel,
|
||||
armor_stands: horizon_client::join_seq::JoinList<horizon_client::entities::ArmorStand>,
|
||||
/// Worn armor as of the last `collectEquipmentChanges` look (frost walker).
|
||||
location_armor: [Option<ItemStack>; 4],
|
||||
/// `Cushion` decoration entities (game/cushions.rs).
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
/// Entities of types Horizon does not simulate.
|
||||
@@ -3001,6 +3058,9 @@ struct Game<'a> {
|
||||
carts: horizon_client::join_seq::JoinList<minecarts::Minecart>,
|
||||
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
|
||||
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
|
||||
/// What the mods asked for server players out of reach (`native_teleport_player`...),
|
||||
/// done after the players' packets of the tick (native_travel.rs).
|
||||
native_moves: Vec<(u128, native_travel::NativeMove)>,
|
||||
/// Songs playing from jukeboxes (tracked sound handles).
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
bolts: Vec<lightning::Bolt>,
|
||||
@@ -3135,6 +3195,10 @@ struct Game<'a> {
|
||||
native_configs: native_configs::NativeConfigs,
|
||||
/// Native mods' messages waiting (`game/native_net.rs`).
|
||||
native_net: native_net::NativeNet,
|
||||
/// What native mods send one player, waiting for the hub (`game/native_player_fx.rs`).
|
||||
native_player_fx: native_player_fx::NativePlayerFx,
|
||||
/// Native mods' kicks waiting for the tick's end (`game/native_packets.rs`).
|
||||
native_packets: native_packets::NativePackets,
|
||||
/// The mod whose own screen is open (`Screen::Mod`).
|
||||
native_mod_screen: Option<String>,
|
||||
/// Meshed sections waiting for an upload budget (Horizon settings).
|
||||
@@ -3281,6 +3345,9 @@ struct Game<'a> {
|
||||
/// The last `use_hand` that ended the click did so with `FAIL`, which
|
||||
/// does not consume the action (handleUseItemOn's ANY_BLOCK_USE).
|
||||
use_failed: bool,
|
||||
/// A mod canceled a use the acting guest's client predicted (a throw, a
|
||||
/// placement): their inventory goes out whole when the turn ends.
|
||||
guest_inventory_resend: bool,
|
||||
/// Level events of this tick for the guests, and the guest acting now.
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
/// Jukebox level events 1010 (song name) and 1011 (`None`) for the guests;
|
||||
@@ -3333,6 +3400,9 @@ struct Game<'a> {
|
||||
lan_acting_name: Option<String>,
|
||||
/// Their UUID (`Player.getUUID`).
|
||||
lan_acting_uuid: Option<u128>,
|
||||
/// The player and the stack on their cursor as they clicked a native
|
||||
/// menu, while `native_menu_clicked` hears it (`native_menu_cursor`).
|
||||
native_click_cursor: Option<(u128, Option<horizon_client::items::ItemStack>)>,
|
||||
/// Where the mods refused to prime TNT: the sound and game event the level
|
||||
/// queued right after the prime are dropped (`prime_tnt_event`).
|
||||
tnt_refused: Option<BlockPos>,
|
||||
@@ -3341,6 +3411,9 @@ struct Game<'a> {
|
||||
/// Their entity id, which the hub (often taken out of `lan` while they
|
||||
/// act) would otherwise have to be asked for.
|
||||
lan_acting_entity: Option<u32>,
|
||||
/// The player `native_player_login` hears and their permission level, while it runs
|
||||
/// (`native_player_permission`): the hub is out of reach there.
|
||||
native_login_permission: Option<(u128, u8)>,
|
||||
/// Command feedback to forward to a guest.
|
||||
chat_capture: Option<Vec<horizon_client::text::Text>>,
|
||||
/// Packets for the guest acting now, sent once its action returns.
|
||||
@@ -3348,6 +3421,9 @@ struct Game<'a> {
|
||||
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
|
||||
/// hub's item registry, which the guest's turn does not hold.
|
||||
guest_cooldowns: Vec<(Arc<str>, i32)>,
|
||||
/// Players whose command tree the mods asked to send again
|
||||
/// (`native_resend_commands`), sent in the next server tick.
|
||||
native_resend_trees: Vec<u128>,
|
||||
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
|
||||
/// sent with their entity id, which the guest's turn does not hold.
|
||||
guest_entity_events: Vec<i8>,
|
||||
@@ -4374,6 +4450,15 @@ impl Game<'_> {
|
||||
self.award_stat("dropped", &thrown.item, thrown.count as i32);
|
||||
self.award_custom_stat("drop", 1);
|
||||
let player = self.native_acting_id();
|
||||
let mut cancel = false;
|
||||
self.native_item_dropped_v2(player, &thrown, &mut cancel);
|
||||
if cancel {
|
||||
// The stack stays the player's: what doesn't fit is thrown after all.
|
||||
if let Some(rest) = self.give(thrown) {
|
||||
self.throw_from_eyes(rest, eye, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.native_item_dropped(player, &thrown.item, thrown.count);
|
||||
self.throw_from_eyes(thrown, eye, true);
|
||||
}
|
||||
@@ -4650,7 +4735,7 @@ impl Game<'_> {
|
||||
&& b.max_position().z > min[2];
|
||||
// ItemEntity.playerTouch: no pickup delay, and the target (if any) is this player.
|
||||
if touching && item.pickup_delay == 0 && item.target.is_none_or(|t| t == own) {
|
||||
if self.native_pickup_refused(&item.stack) {
|
||||
if self.native_pickup_refused(&item.stack, item.thrower, item.position) {
|
||||
item.pickup_delay = 10;
|
||||
kept.push(item);
|
||||
continue;
|
||||
@@ -5603,9 +5688,10 @@ impl Game<'_> {
|
||||
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
|
||||
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
|
||||
}
|
||||
// LevelEventHandler 2012: a turtle egg laid.
|
||||
// LevelEventHandler 2011 and 2012: a bee growing a crop, a
|
||||
// turtle egg laid.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2012,
|
||||
id: 2011 | 2012,
|
||||
pos,
|
||||
data,
|
||||
} => self.turtle_egg_particles(pos, data),
|
||||
@@ -6162,11 +6248,12 @@ impl Game<'_> {
|
||||
/// One falling block's `tick`: places or drops its block in its own tick,
|
||||
/// so a block falling onto one that just landed lands on top of it. `i` is
|
||||
/// its place in the list; the caller has checked it is ticking.
|
||||
fn tick_one_falling(&mut self, i: usize) -> Result<()> {
|
||||
/// `entities` are the boxes that stop its move (`Game::item_entity_collisions`).
|
||||
fn tick_one_falling(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
|
||||
let entity_drops = self.ui.rules.bool("entity_drops");
|
||||
let mut f = self.falling.remove(i);
|
||||
let original = f.clone();
|
||||
match self.fall_step(&mut f, entity_drops) {
|
||||
match self.fall_step(&mut f, entity_drops, entities) {
|
||||
Ok(FallStep::Falling) => self.falling.insert(i.min(self.falling.len()), f),
|
||||
Ok(FallStep::Gone) => {}
|
||||
Ok(FallStep::Dropped(at)) => self.drop_falling_block(f.state, at),
|
||||
@@ -6179,7 +6266,12 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// One `FallingBlockEntity.tick` move of `f`.
|
||||
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
|
||||
fn fall_step(
|
||||
&mut self,
|
||||
f: &mut FallingBlock,
|
||||
entity_drops: bool,
|
||||
entities: &[Aabb],
|
||||
) -> Result<FallStep> {
|
||||
f.previous = f.position;
|
||||
f.age = f.age.wrapping_add(1);
|
||||
f.velocity.y -= horizon_client::entity_rules::EntityRules::get_gravity(f);
|
||||
@@ -6213,10 +6305,11 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
// On an error the caller keeps the block as it was before this tick.
|
||||
// Entity.collide: the world border's walls stop it too.
|
||||
// Entity.collide: the world border's walls and the entities that
|
||||
// `canBeCollidedWith` it (a boat, a shulker) stop it too.
|
||||
let movement = collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &[],
|
||||
entities,
|
||||
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
|
||||
}
|
||||
.resolve_with_context(
|
||||
@@ -6729,6 +6822,7 @@ impl Game<'_> {
|
||||
}
|
||||
if !claimed {
|
||||
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
|
||||
self.native_interact_entity_v2(id, &kind, self.hand, stack.as_ref(), &mut claimed);
|
||||
}
|
||||
if claimed {
|
||||
self.swing_arm();
|
||||
@@ -6827,6 +6921,8 @@ impl Game<'_> {
|
||||
let mut canceled = false;
|
||||
horizon_client::spans::time("use_mods", || {
|
||||
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
|
||||
let hand = self.hand;
|
||||
self.native_right_click_block_v2(hit.pos, hit.face, hand, &mut canceled);
|
||||
});
|
||||
if canceled {
|
||||
// Nothing was used, so nothing consumed the action.
|
||||
@@ -7012,7 +7108,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if result.should_swing() {
|
||||
self.swing_arm();
|
||||
// SUCCESS swings on the client too; the beds, light,
|
||||
// respawn anchor and sign's own uses are SUCCESS_SERVER.
|
||||
if server_only_block_use(self.res.facts().get(cake), branch) {
|
||||
self.swing_arm();
|
||||
} else {
|
||||
self.swing_arm_predicted();
|
||||
}
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -7252,6 +7354,13 @@ impl Game<'_> {
|
||||
item: stack.item.to_string(),
|
||||
can_use_game_master_blocks: self.can_use_game_master_blocks(),
|
||||
};
|
||||
// The mods, once the position and state are known: a canceled
|
||||
// placement is a failed one, and the client's prediction is undone.
|
||||
if self.block_place_canceled(&stack, &ctx) {
|
||||
self.use_failed = true;
|
||||
self.guest_inventory_resend = true;
|
||||
return Ok(true);
|
||||
}
|
||||
let outcome = self.with_level(|b, level, ticks| b.place_block(level, ticks, &ctx));
|
||||
let PlaceOutcome::Placed { pos, state } = outcome else {
|
||||
self.process_level_events()?;
|
||||
@@ -7290,7 +7399,8 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.swing_arm();
|
||||
// BlockItem.place's SUCCESS: the client swings with its own prediction.
|
||||
self.swing_arm_predicted();
|
||||
// BlockItem.place's sound is among the level events;
|
||||
// SolidBucketItem.getPlaceSound replaces it with the bucket's own.
|
||||
let solid_bucket = &*stack.item == "minecraft:powder_snow_bucket";
|
||||
@@ -7317,6 +7427,30 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// Asks the mods whether the acting guest may place `stack` where and as
|
||||
/// `BlockItem.place` would for `ctx` (`native_block_place`): true when
|
||||
/// one canceled it. A game's own player is not asked, nor a placement
|
||||
/// vanilla refuses.
|
||||
fn block_place_canceled(&mut self, stack: &ItemStack, ctx: &PlaceContext) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let Some((pos, state)) =
|
||||
self.with_level(|b, level, ticks| b.placement_state(level, ticks, ctx))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_block_place(
|
||||
uuid,
|
||||
&name,
|
||||
stack,
|
||||
[pos.x, pos.y, pos.z],
|
||||
state,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// Asks the mods whether the acting guest may empty `stack` at `pos`
|
||||
/// (`native_bucket_empty`): true when one cancelled it. A game's own
|
||||
/// player is not asked.
|
||||
@@ -7423,6 +7557,7 @@ impl Game<'_> {
|
||||
);
|
||||
instance.ambient = eff.ambient;
|
||||
instance.visible = eff.show_particles;
|
||||
instance.show_icon = eff.show_icon;
|
||||
self.add_player_effect(instance);
|
||||
}
|
||||
}
|
||||
@@ -7550,12 +7685,18 @@ impl Game<'_> {
|
||||
.instrument(&stack)
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
// InstrumentItem.getUseDuration: held up that long, nothing more.
|
||||
self.using = (ticks + 1 < duration).then_some((slot, ticks + 1));
|
||||
// InstrumentItem.getUseDuration: held up that long. When it runs
|
||||
// out, LivingEntity.stopUsingItem ends the use and causes the
|
||||
// interaction vibration; letting go of the button already does.
|
||||
if ticks + 1 < duration {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
} else {
|
||||
self.stop_using_item(&stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:brush" {
|
||||
self.tick_brush(slot, ticks);
|
||||
self.tick_brush(&stack, slot, ticks);
|
||||
return;
|
||||
}
|
||||
if info.bundle {
|
||||
@@ -8062,6 +8203,11 @@ impl Game<'_> {
|
||||
self.world.weather.tick_timers(&mut self.world.random);
|
||||
}
|
||||
self.world.weather.tick_levels();
|
||||
} else if self.clock.still() {
|
||||
// Under `HORIZON_STILL` a frozen world's rain and thunder sit at their levels, as a
|
||||
// shader pack's smoothed values do, so `/weather rain` shows full rain without
|
||||
// stepping the world (and its mobs) through the fade.
|
||||
self.world.weather.settle_levels();
|
||||
}
|
||||
// ClientLevel.tick: the sky flash fades in a frozen level too.
|
||||
self.tick_weather_effects();
|
||||
@@ -9059,9 +9205,6 @@ impl Game<'_> {
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let show_hud = !self.ui.hide_gui;
|
||||
// max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and
|
||||
// the action bar sit above it.
|
||||
let mut bar_height = 39;
|
||||
if show_hud {
|
||||
self.draw_spyglass(&mut batch);
|
||||
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
|
||||
@@ -9160,8 +9303,6 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.draw_mount_hud(&mut batch, wi, hi)
|
||||
};
|
||||
// Without the survival bars only the mount's hearts raise it.
|
||||
bar_height = 39 + 10 * ((vehicle_hearts + 9) / 10);
|
||||
// The one bar above the hotbar: the mount's jump bar, the locator
|
||||
// bar (with its background) or the experience bar, with the level
|
||||
// number that a survival player always has.
|
||||
@@ -9254,10 +9395,6 @@ impl Game<'_> {
|
||||
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
|
||||
hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
});
|
||||
bar_height = self
|
||||
.health_hud
|
||||
.left_height
|
||||
.max(self.health_hud.right_height);
|
||||
// Hud.playAirBubblePoppedSound: Player.playSound (PLAYERS), heard
|
||||
// by this client only.
|
||||
if let Some((volume, pitch)) = self.health_hud.bubble_pop_sound.take() {
|
||||
@@ -9273,7 +9410,7 @@ impl Game<'_> {
|
||||
}
|
||||
if !spectator {
|
||||
self.gui_layer("minecraft:selected_item_name", &mut batch, |g, batch| {
|
||||
g.draw_selected_name(batch, bar_height, !g.creative);
|
||||
g.draw_selected_name(batch, !g.creative);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9284,7 +9421,7 @@ impl Game<'_> {
|
||||
});
|
||||
if show_hud {
|
||||
self.gui_layer("minecraft:overlay_message", &mut batch, |g, batch| {
|
||||
g.draw_overlay_message(batch, bar_height);
|
||||
g.draw_overlay_message(batch);
|
||||
});
|
||||
self.gui_layer("minecraft:title", &mut batch, |g, batch| {
|
||||
g.draw_title(batch);
|
||||
@@ -10438,18 +10575,27 @@ impl Game<'_> {
|
||||
if let Some((s, mob)) = session.as_mut() {
|
||||
self.merchant_sync_in(s, *mob);
|
||||
}
|
||||
// The cartography table reads and makes the level's maps; a
|
||||
// Crafting and cartography read and derive the level's maps; a
|
||||
// server's (the client menu's ContainerLevelAccess.NULL) doesn't.
|
||||
let level_maps = (kind == Kind::Cartography && self.remote.is_none()).then(|| {
|
||||
let held: Vec<ItemStack> = before
|
||||
let level_maps = (matches!(kind, Kind::Cartography | Kind::Crafting | Kind::Inventory)
|
||||
&& self.remote.is_none())
|
||||
.then(|| {
|
||||
before
|
||||
.iter()
|
||||
.flatten()
|
||||
.chain(self.inventory.iter().flatten())
|
||||
.chain(self.carried.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
self.level_maps(&held)
|
||||
});
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|held| {
|
||||
kind == Kind::Cartography
|
||||
|| held
|
||||
.iter()
|
||||
.filter_map(maps::map_id)
|
||||
.any(|id| self.map(id).is_some())
|
||||
})
|
||||
.map(|held| self.level_maps(&held));
|
||||
let next_map = if merchant_open { self.peek_map_id() } else { 0 };
|
||||
let tag = |t: &str| self.structure_tag(t);
|
||||
let maps = trading::GameMaps {
|
||||
@@ -10559,6 +10705,9 @@ impl Game<'_> {
|
||||
let predicting = self.remote.is_some();
|
||||
if !predicting {
|
||||
self.register_exploration_maps(made);
|
||||
for (item, count) in std::mem::take(&mut menu.crafted_counts) {
|
||||
self.award_stat("crafted", &item, count);
|
||||
}
|
||||
}
|
||||
if let Some(s) = menu.merchant.take() {
|
||||
self.merchant_sync_out(s);
|
||||
@@ -12287,8 +12436,12 @@ impl Game<'_> {
|
||||
if let Some(hit) = self.tick_mob {
|
||||
return hit;
|
||||
}
|
||||
self.mob_hit_within(self.entity_reach)
|
||||
}
|
||||
/// [`Game::mob_hit`] out to `reach` instead of the entity interaction range
|
||||
/// (`ProjectileUtil.getHitResultOnViewVector` with another range).
|
||||
pub(super) fn mob_hit_within(&self, reach: f64) -> Option<(usize, f64, [f64; 3])> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach: f64 = self.entity_reach;
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
// ProjectileUtil.getEntityHitResult: the mob the player rides
|
||||
@@ -13804,6 +13957,9 @@ impl Game<'_> {
|
||||
&& let Ok(new) = registry.resolve(&block)
|
||||
{
|
||||
self.level_set(pos, new)?;
|
||||
if result.lower_fill_level {
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
}
|
||||
if let Some(sound) = result.sound {
|
||||
self.audio.play(
|
||||
@@ -14360,12 +14516,19 @@ impl Game<'_> {
|
||||
/// `ExperienceOrb.award`: orbs of the largest sizes; each draws the id
|
||||
/// `tryMergeToExisting` compares from the level random, then moves with
|
||||
/// its own random.
|
||||
fn spawn_orbs(&mut self, at: [f64; 3], mut value: u32) {
|
||||
fn spawn_orbs(&mut self, at: [f64; 3], value: u32) {
|
||||
self.spawn_orbs_toward(at, [0.0; 3], value);
|
||||
}
|
||||
/// `ExperienceOrb.awardWithDirection`: `award` whose fresh orbs start
|
||||
/// `roughly` that way (`new ExperienceOrb(level, pos, roughly, value)`).
|
||||
fn spawn_orbs_toward(&mut self, at: [f64; 3], roughly: [f64; 3], mut value: u32) {
|
||||
while value > 0 {
|
||||
let v = ExperienceOrb::split(value);
|
||||
value -= v;
|
||||
let merge_id = self.world.random.next_int_bound(40).unwrap_or(0);
|
||||
self.award_orb(at, v, merge_id);
|
||||
if !self.merge_new_orb(at, v, merge_id) {
|
||||
self.add_orb_toward(at, roughly, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.tryMergeToExisting`: an orb of the same value within a
|
||||
@@ -14373,6 +14536,12 @@ impl Game<'_> {
|
||||
/// the new one as one more in its count (and restarts its age); otherwise
|
||||
/// a fresh orb spawns.
|
||||
fn award_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) {
|
||||
if !self.merge_new_orb(at, value, merge_id) {
|
||||
self.add_orb(at, value);
|
||||
}
|
||||
}
|
||||
/// `tryMergeToExisting`: true when an orb took the new one.
|
||||
fn merge_new_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) -> bool {
|
||||
let centre = Vec3::new(at[0], at[1], at[2]);
|
||||
let half = Vec3::new(0.5, 0.5, 0.5);
|
||||
let area = Aabb::new(centre.subtract(half), centre.add_vector(half));
|
||||
@@ -14394,8 +14563,9 @@ impl Game<'_> {
|
||||
if let Some(i) = target {
|
||||
self.orbs[i].count += 1;
|
||||
self.orbs[i].age = 0;
|
||||
true
|
||||
} else {
|
||||
self.add_orb(at, value);
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Whether orb `into` takes orb `from` (0: a spawning orb of `from_value`),
|
||||
@@ -14469,10 +14639,33 @@ impl Game<'_> {
|
||||
/// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from
|
||||
/// the orb's own random.
|
||||
fn add_orb(&mut self, at: [f64; 3], value: u32) {
|
||||
let at = self.unstuck_orb(at);
|
||||
self.add_orb_toward(at, [0.0; 3], value);
|
||||
}
|
||||
/// `new ExperienceOrb(level, pos, roughly, value)`: a random motion that
|
||||
/// points against `roughly` is turned round, and the orb starts half its
|
||||
/// size (0.25) along `roughly`, unstuck if it collides there.
|
||||
fn add_orb_toward(&mut self, at: [f64; 3], roughly: [f64; 3], value: u32) {
|
||||
let len_sqr = roughly.iter().map(|v| v * v).sum::<f64>();
|
||||
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
|
||||
let len = len_sqr.sqrt();
|
||||
let unit = if len < 1.0e-5 {
|
||||
[0.0; 3]
|
||||
} else {
|
||||
roughly.map(|v| v / len)
|
||||
};
|
||||
let at = self.unstuck_orb([0, 1, 2].map(|a| at[a] + unit[a] * 0.25));
|
||||
let mut r = dispenser::entity_random();
|
||||
let _yaw = r.next_float();
|
||||
let p = Vec3::new(at[0], at[1], at[2]);
|
||||
let mut velocity = Vec3::new(
|
||||
(r.next_double() * 0.2 - 0.1) * 2.0,
|
||||
r.next_double() * 0.2 * 2.0,
|
||||
(r.next_double() * 0.2 - 0.1) * 2.0,
|
||||
);
|
||||
let dot = roughly[0] * velocity.x + roughly[1] * velocity.y + roughly[2] * velocity.z;
|
||||
if len_sqr > 0.0 && dot < 0.0 {
|
||||
velocity = Vec3::new(-velocity.x, -velocity.y, -velocity.z);
|
||||
}
|
||||
self.next_entity += 1;
|
||||
self.orbs.push(ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
@@ -14481,11 +14674,7 @@ impl Game<'_> {
|
||||
id: self.next_entity,
|
||||
position: p,
|
||||
previous: p,
|
||||
velocity: Vec3::new(
|
||||
(r.next_double() * 0.2 - 0.1) * 2.0,
|
||||
r.next_double() * 0.2 * 2.0,
|
||||
(r.next_double() * 0.2 - 0.1) * 2.0,
|
||||
),
|
||||
velocity,
|
||||
value,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
@@ -15157,7 +15346,7 @@ impl Game<'_> {
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
@@ -15198,7 +15387,7 @@ impl Game<'_> {
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
@@ -16327,6 +16516,17 @@ impl Game<'_> {
|
||||
// (`take_parent_vehicle`); here it stays in the chunk saves.
|
||||
let mut host = host;
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
// Read-only fixture input, correlated with the real save receipt.
|
||||
// Keep this identical on the untouched/fixed selector builds.
|
||||
if std::env::var_os("HORIZON_DEV_ADVANCEMENT_SELECTOR_AUDIT").is_some() {
|
||||
eprintln!(
|
||||
"advancement selector model raw: {}",
|
||||
serde_json::json!({"conn":g.conn,"name":g.name,
|
||||
"uuid":horizon_protocol::crypto::uuid_string(g.uuid),
|
||||
"game_time":self.world.game_time,
|
||||
"progress":g.advancements.to_json(&self.adv.tree, horizon_world::DATA_VERSION)})
|
||||
);
|
||||
}
|
||||
self.guest_attachments_into(g);
|
||||
self.player_autosaves.enqueue(g, &self.world_root);
|
||||
}
|
||||
@@ -16892,6 +17092,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
};
|
||||
self.custom_sky_frame([camera.x, camera.y, camera.z], &sky, (rain, thunder), title);
|
||||
pr.lap("pr_celestial");
|
||||
let (mut hand, hand_items) = if title || self.scoping() {
|
||||
Default::default()
|
||||
@@ -17568,6 +17769,7 @@ impl Game<'_> {
|
||||
_ if self.screen == Screen::Dead => {
|
||||
self.death_key(key)?;
|
||||
}
|
||||
_ if self.screen == Screen::Stats => self.stats_key(raw),
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
@@ -18210,13 +18412,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
if fullscreen && window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor());
|
||||
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
|
||||
}
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
@@ -18628,6 +18824,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Title boxes keep their text on the key path. A field hook takes
|
||||
// it through the same per-character editor as IME and control input.
|
||||
if !canceled
|
||||
&& control::title_owns_text(&self.screen, self.title.is_some())
|
||||
&& self.types_text()
|
||||
&& let Some(text) = key_event.text.as_deref()
|
||||
&& let Some(result) = self.type_text_field_input(text)
|
||||
{
|
||||
if let Err(error) = result {
|
||||
log::warn!("Horizon: text input: {error}");
|
||||
}
|
||||
key_event.text = None;
|
||||
}
|
||||
if canceled {
|
||||
// charTyped is a separate callback: the characters still
|
||||
// reach the text box, without the key's own effects. Options'
|
||||
@@ -20222,6 +20431,7 @@ impl<'a> Game<'a> {
|
||||
level_up_sound: Default::default(),
|
||||
swing: Default::default(),
|
||||
swings_started: 0,
|
||||
predicted_swings: 0,
|
||||
heights: Default::default(),
|
||||
sway: Default::default(),
|
||||
visible_hands: [(0, None), (0, None)],
|
||||
@@ -20239,6 +20449,7 @@ impl<'a> Game<'a> {
|
||||
dragging: false,
|
||||
avatar: Default::default(),
|
||||
flash: 0,
|
||||
custom_sky: Default::default(),
|
||||
rain_sound_time: 0,
|
||||
lightning_seen: [0; 2],
|
||||
sleeping: None,
|
||||
@@ -20274,6 +20485,7 @@ impl<'a> Game<'a> {
|
||||
map_frame_model: prepared.map_frame_model,
|
||||
glow_map_frame_model: prepared.glow_map_frame_model,
|
||||
armor_stands: Default::default(),
|
||||
location_armor: Default::default(),
|
||||
cushions: Vec::new(),
|
||||
others: Vec::new(),
|
||||
armor_stand_assets: prepared.armor_stand_assets,
|
||||
@@ -20293,6 +20505,7 @@ impl<'a> Game<'a> {
|
||||
entity_models: prepared.entity_models,
|
||||
carts: Default::default(),
|
||||
watched_vehicles: Vec::new(),
|
||||
native_moves: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
maps: maps::Maps::default(),
|
||||
@@ -20391,6 +20604,8 @@ impl<'a> Game<'a> {
|
||||
native_lifecycle: Default::default(),
|
||||
native_configs: Default::default(),
|
||||
native_net: Default::default(),
|
||||
native_player_fx: Default::default(),
|
||||
native_packets: Default::default(),
|
||||
native_mod_screen: None,
|
||||
sky_darken_memo: Default::default(),
|
||||
water_plane_memo: Default::default(),
|
||||
@@ -20448,6 +20663,7 @@ impl<'a> Game<'a> {
|
||||
block_reach: 4.5,
|
||||
guest_use: None,
|
||||
use_failed: false,
|
||||
guest_inventory_resend: false,
|
||||
lan_events: Vec::new(),
|
||||
lan_songs: Vec::new(),
|
||||
lan_cushion_breaks: Vec::new(),
|
||||
@@ -20469,14 +20685,17 @@ impl<'a> Game<'a> {
|
||||
lan_break_pos: None,
|
||||
guest_packets: Vec::new(),
|
||||
guest_cooldowns: Vec::new(),
|
||||
native_resend_trees: Vec::new(),
|
||||
guest_entity_events: Vec::new(),
|
||||
guest_equipment_breaks: Vec::new(),
|
||||
guest_player_sounds: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
native_click_cursor: None,
|
||||
tnt_refused: None,
|
||||
lan_acting_permission: None,
|
||||
lan_acting_entity: None,
|
||||
native_login_permission: None,
|
||||
chat_capture: None,
|
||||
admin_feedback: Vec::new(),
|
||||
console_broadcast: None,
|
||||
@@ -21894,7 +22113,7 @@ fn build_renderer(
|
||||
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
|
||||
// offered (`Renderer::new`).
|
||||
wide_frame: bool,
|
||||
shader_pack: bool,
|
||||
shader_pack: shader_packs::PickedPack,
|
||||
filtering: (i32, i32),
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
@@ -21933,7 +22152,9 @@ fn build_renderer(
|
||||
api,
|
||||
translucent_batching,
|
||||
wide_frame,
|
||||
shader_pack,
|
||||
shader_pack.draws_flat,
|
||||
shader_pack.linear,
|
||||
shader_pack.hdr_float,
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
@@ -21946,6 +22167,7 @@ fn build_renderer(
|
||||
pack.item_renderer
|
||||
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
|
||||
renderer.set_lines_shader(atlas.lines_shader.as_deref());
|
||||
renderer.set_custom_sky(&pack.custom_sky);
|
||||
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
|
||||
renderer
|
||||
.hud
|
||||
@@ -22005,6 +22227,8 @@ fn loading_renderer(
|
||||
// An 8-bit frame, which HDR output takes as well.
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.sprite_animations = atlas
|
||||
@@ -22426,13 +22650,7 @@ impl Loading {
|
||||
return;
|
||||
}
|
||||
};
|
||||
if fullscreen && window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor());
|
||||
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
|
||||
}
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
@@ -22623,7 +22841,7 @@ impl Loading {
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
@@ -22907,11 +23125,17 @@ pub fn run_with() -> Result<()> {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// The packets the server sends its players reach the mods that ask (native_packets.rs).
|
||||
let _ = horizon_client::lan::conn::SERVER_PACKET.set(Game::native_server_packet);
|
||||
let _ = horizon_client::lan::conn::ENTITY_DATA_TO.set(Game::native_entity_data_to);
|
||||
let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server);
|
||||
let _ = horizon_client::health::DAMAGE_PRE.set(native_hooks::damage_pre);
|
||||
let _ = horizon_client::health::DAMAGE_POST.set(native_hooks::damage_post);
|
||||
let _ = horizon_client::block_contact::HOOK.set(native_hooks::block_contact);
|
||||
let _ = horizon_client::health::KNOCKBACK.set(native_hooks::knockback);
|
||||
let _ = horizon_client::lan::hub::PLAYER_MOVE.set(Game::native_player_move);
|
||||
let _ = horizon_client::lan::hub::PLAYER_TELEPORT.set(Game::native_player_teleport);
|
||||
let _ = horizon_client::block_contact::TRAMPLE.set(Game::native_trample);
|
||||
// Before any worker starts: on Linux they go behind the server thread
|
||||
// and the render thread.
|
||||
horizon_threads::put_helpers_behind();
|
||||
@@ -22939,7 +23163,7 @@ pub fn run_with() -> Result<()> {
|
||||
{
|
||||
options.spikes = Some(dedicated::SLOW_TICK_MS);
|
||||
}
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
|
||||
// A player who allows performance statistics also says what their slow frames spent their
|
||||
// time in: this thread, which draws the frames, times its steps (wall time only).
|
||||
if options.execution_role.is_client()
|
||||
@@ -24213,3 +24437,6 @@ fn write_level_for_role(
|
||||
level.write_without_player(dir)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vibration_tests;
|
||||
@@ -392,6 +392,15 @@ impl Game<'_> {
|
||||
let tree = self.adv.tree.clone();
|
||||
self.adv.progress = progress;
|
||||
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
|
||||
// `PlayerAdvancements.checkForAutomaticTriggers`: an advancement with no
|
||||
// criteria is never done (`AdvancementRequirements.test` is false), but
|
||||
// its rewards are granted as the progress loads. Vanilla's `award(holder, "")` finds no `""`
|
||||
// criterion to grant and so returns without a toast or an announcement;
|
||||
// the reward grant is the whole effect, and it happens again on the next
|
||||
// load because there is no criterion to record it.
|
||||
for index in tree.automatic() {
|
||||
self.grant_rewards(index);
|
||||
}
|
||||
}
|
||||
pub(super) fn save_advancements(&self) {
|
||||
if !self.adv.loaded || self.remote.is_some() || self.no_player_here() {
|
||||
@@ -920,47 +929,7 @@ impl Game<'_> {
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_advancement_done(player, &name, &a.id);
|
||||
}
|
||||
// AdvancementRewards.grant
|
||||
if a.rewards.experience > 0 {
|
||||
let old_level = self.experience.level;
|
||||
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
|
||||
let gained_level = self.experience.give(points);
|
||||
if let Some(level) = gained_level {
|
||||
self.play_level_up(1, level);
|
||||
}
|
||||
self.native_player_level_changed(old_level);
|
||||
}
|
||||
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
|
||||
let rewards: Vec<String> = a
|
||||
.rewards
|
||||
.recipes
|
||||
.iter()
|
||||
.filter(|r| world_stations!(self).recipe_index.contains(r))
|
||||
.cloned()
|
||||
.collect();
|
||||
let (_, unlocked) = self
|
||||
.level
|
||||
.recipe_book
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
|
||||
for s in items {
|
||||
// Inventory.add returns false when anything is left, and
|
||||
// AdvancementRewards then drops that remainder with no
|
||||
// pickup sound. A stack the inventory takes whole plays
|
||||
// entity.item.pickup at the player's feet.
|
||||
if let Some(rest) = self.give(s) {
|
||||
if let Some(eye) = self.eye_position() {
|
||||
self.spawn_item(rest, eye, [0.0; 3], 0);
|
||||
}
|
||||
} else {
|
||||
self.advancement_pickup_sound();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(function) = &a.rewards.function {
|
||||
self.run_advancement_reward_function(function);
|
||||
}
|
||||
let unlocked = self.grant_rewards(index);
|
||||
if let Some(d) = &a.display {
|
||||
// A network player's client shows its own toast.
|
||||
if d.show_toast
|
||||
@@ -1012,6 +981,56 @@ impl Game<'_> {
|
||||
}
|
||||
self.recipe_unlocked(&unlocked);
|
||||
}
|
||||
/// `AdvancementRewards.grant`: the experience, recipes, loot and function of
|
||||
/// a finished advancement, and the recipes it unlocked for the
|
||||
/// `recipe_unlocked` trigger, which the caller runs after its toast and
|
||||
/// announcement. Returns those recipes.
|
||||
fn grant_rewards(&mut self, index: usize) -> Vec<String> {
|
||||
let tree = self.adv.tree.clone();
|
||||
let a = &tree.list[index];
|
||||
// AdvancementRewards.grant
|
||||
if a.rewards.experience > 0 {
|
||||
let old_level = self.experience.level;
|
||||
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
|
||||
let gained_level = self.experience.give(points);
|
||||
if let Some(level) = gained_level {
|
||||
self.play_level_up(1, level);
|
||||
}
|
||||
self.native_player_level_changed(old_level);
|
||||
}
|
||||
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
|
||||
let rewards: Vec<String> = a
|
||||
.rewards
|
||||
.recipes
|
||||
.iter()
|
||||
.filter(|r| world_stations!(self).recipe_index.contains(r))
|
||||
.cloned()
|
||||
.collect();
|
||||
let (_, unlocked) = self
|
||||
.level
|
||||
.recipe_book
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
|
||||
for s in items {
|
||||
// Inventory.add returns false when anything is left, and
|
||||
// AdvancementRewards then drops that remainder with no
|
||||
// pickup sound. A stack the inventory takes whole plays
|
||||
// entity.item.pickup at the player's feet.
|
||||
if let Some(rest) = self.give(s) {
|
||||
if let Some(eye) = self.eye_position() {
|
||||
self.spawn_item(rest, eye, [0.0; 3], 0);
|
||||
}
|
||||
} else {
|
||||
self.advancement_pickup_sound();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(function) = &a.rewards.function {
|
||||
self.run_advancement_reward_function(function);
|
||||
}
|
||||
unlocked
|
||||
}
|
||||
/// `InventoryChangeTrigger`, checked when the inventory changes.
|
||||
pub(super) fn check_inventory_trigger(&mut self) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
@@ -1647,7 +1647,9 @@ impl Game<'_> {
|
||||
}
|
||||
if text == "\\b" {
|
||||
title.backspace();
|
||||
} else {
|
||||
} else if let Some(result) = self.type_text_field_input(text) {
|
||||
result?;
|
||||
} else if let Some(title) = self.title.as_mut() {
|
||||
title.type_text(text);
|
||||
}
|
||||
ok(json!({ "ok": true, "menu": self.title_value() }))
|
||||
|
||||
@@ -151,6 +151,13 @@ impl Game<'_> {
|
||||
.block_entities
|
||||
.put(key, BlockEntity::Beehive(hive));
|
||||
if stored {
|
||||
let state = self.world.block(at[0], at[1], at[2]);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
at,
|
||||
Some(Who::Mob(mob)),
|
||||
Some(state),
|
||||
);
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let block = self
|
||||
.res
|
||||
@@ -268,7 +275,9 @@ impl Game<'_> {
|
||||
} else if let Some(rest) = self.give(bottle)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
|
||||
// `player.drop(stack, false, PREDICTED)`: thrown from below
|
||||
// the eyes along the view, with no thrower.
|
||||
self.throw_from_eyes(rest, eye, false);
|
||||
}
|
||||
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
|
||||
}
|
||||
@@ -368,3 +377,51 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod honey_overflow_tests {
|
||||
use super::*;
|
||||
|
||||
/// `BeehiveBlock.useItemOn`: a honey bottle that no longer fits is
|
||||
/// `player.drop(stack, false, PREDICTED)`: from 0.3 below the eyes,
|
||||
/// thrown along the view, with no thrower (#11377).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_honey_bottle_that_does_not_fit_is_thrown_ahead() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let registry = &game.pack.assets.registry;
|
||||
let hive = registry.default_state("minecraft:beehive").unwrap();
|
||||
let mut full = registry.state(hive).unwrap().clone();
|
||||
full.properties.insert("honey_level".into(), "5".into());
|
||||
let full = registry.id(&full).unwrap();
|
||||
game.world.set(8, 64, 10, full).unwrap();
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 64.0, 8.5))
|
||||
.unwrap(),
|
||||
);
|
||||
// Facing south (+z), towards the hive.
|
||||
game.yaw = 0.0;
|
||||
game.pitch = 0.0;
|
||||
for slot in game.inventory.iter_mut() {
|
||||
*slot = Some(ItemStack::new("minecraft:stone", 64));
|
||||
}
|
||||
game.selected = 0;
|
||||
let bottles = ItemStack::new("minecraft:glass_bottle", 2);
|
||||
game.inventory[0] = Some(bottles.clone());
|
||||
game.dropped.clear();
|
||||
|
||||
assert!(game.use_beehive([8, 64, 10], Some(&bottles)).unwrap());
|
||||
let eye = game.eye_position().unwrap();
|
||||
let honey: Vec<_> = game
|
||||
.dropped
|
||||
.iter()
|
||||
.filter(|e| &*e.stack.item == "minecraft:honey_bottle")
|
||||
.collect();
|
||||
assert_eq!(honey.len(), 1);
|
||||
let e = honey[0];
|
||||
assert!((e.position.y - (eye[1] - f64::from(0.3f32))).abs() < 1e-9);
|
||||
assert!(e.velocity.z > 0.2, "thrown ahead: {:?}", e.velocity);
|
||||
assert_eq!(e.thrower, None);
|
||||
assert_eq!(e.pickup_delay, 40);
|
||||
}
|
||||
}
|
||||
@@ -506,6 +506,69 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn command_view_uses_live_and_pending_boat_leashes_not_raw_tags() {
|
||||
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
let (id, uuid, actual_holder, unknown) =
|
||||
(1, [326, 1, 0, 1], [326, 2, 0, 1], [111, 222, 333, 444]);
|
||||
game.boats
|
||||
.push(Boat::new(id, "oak".into(), Vec3::new(6.5, 100.0, 4.5), 0.0));
|
||||
game.persist.uuids.insert(id, uuid);
|
||||
game.level.player_uuid = Some([326, 3, 0, 1]);
|
||||
let read = |game: &mut Game<'_>| game.entity_nbt(Target::Boat(id)).unwrap();
|
||||
assert!(!game.persist.raw.contains_key(&id));
|
||||
assert_eq!(
|
||||
read(&mut game).get("UUID"),
|
||||
Some(&Tag::IntArray(uuid.to_vec()))
|
||||
);
|
||||
assert!(read(&mut game).get("leash").is_none());
|
||||
game.boats[0].leash = Some(Holder::Player);
|
||||
game.boats[0].leash_player = Some(actual_holder);
|
||||
let expected = Tag::compound([("UUID", Tag::IntArray(actual_holder.to_vec()))]);
|
||||
assert_eq!(read(&mut game).get("leash"), Some(&expected));
|
||||
assert_eq!(read(&mut game).get("leash"), Some(&expected));
|
||||
let delayed = Tag::compound([("UUID", Tag::IntArray(unknown.to_vec()))]);
|
||||
game.persist
|
||||
.raw
|
||||
.insert(id, Tag::compound([("leash", delayed.clone())]));
|
||||
assert_eq!(read(&mut game).get("leash"), Some(&expected));
|
||||
game.boats[0].leash = None;
|
||||
game.boats[0].leash_player = None;
|
||||
game.boats[0].leash_pending = Some(Pending::Uuid(unknown));
|
||||
assert_eq!(read(&mut game).get("leash"), Some(&delayed));
|
||||
game.boats[0].leash_pending = None;
|
||||
assert!(read(&mut game).get("leash").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boat_is_centred_half_its_height_up() {
|
||||
let b = Boat::new(1, "oak".into(), Vec3::new(1.0, 64.0, 2.0), 0.0);
|
||||
|
||||
@@ -26,6 +26,9 @@ pub(super) struct BossState {
|
||||
loaded: bool,
|
||||
/// What each viewer was last sent, so a change becomes one packet.
|
||||
sent: HashMap<u128, HashMap<u128, Offer>>,
|
||||
/// The bars server mods show single players (`native_boss_bar`): by
|
||||
/// player UUID, each bar's id and bar, in the order they were added.
|
||||
pub(super) native: HashMap<u128, Vec<(u128, horizon_client::mod_api::NativeBossBar)>>,
|
||||
}
|
||||
|
||||
impl BossState {
|
||||
@@ -551,6 +554,20 @@ impl Game<'_> {
|
||||
scope: Scope::Global,
|
||||
});
|
||||
}
|
||||
// Server mods' bars for this player alone (`native_boss_bar`).
|
||||
for (id, bar) in self.bosses.native.get(&viewer.uuid).into_iter().flatten() {
|
||||
out.push(Offer {
|
||||
id: *id,
|
||||
name: horizon_client::lan::data::component_nbt(&bar.name.component()),
|
||||
progress: bar.progress,
|
||||
color: bar.color,
|
||||
overlay: bar.overlay,
|
||||
darken: bar.darken,
|
||||
music: bar.music,
|
||||
fog: bar.fog,
|
||||
scope: Scope::Global,
|
||||
});
|
||||
}
|
||||
if viewer.here
|
||||
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
|
||||
{
|
||||
|
||||
@@ -40,19 +40,49 @@ pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
|
||||
impl Game<'_> {
|
||||
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
|
||||
/// hand of `slot` acting.
|
||||
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
|
||||
// No block under the crosshair: releaseUsingItem.
|
||||
pub(super) fn tick_brush(
|
||||
&mut self,
|
||||
stack: &horizon_client::items::ItemStack,
|
||||
slot: usize,
|
||||
ticks: u32,
|
||||
) {
|
||||
// No block under the crosshair: vanilla calls releaseUsingItem, which
|
||||
// stops the use and causes the interaction vibration.
|
||||
let Some(hit) = self.pick() else {
|
||||
self.using = None;
|
||||
self.stop_using_item(stack);
|
||||
return;
|
||||
};
|
||||
// `BrushItem.onUseTick` aims with `getHitResultOnViewVector` out to the
|
||||
// block interaction range, for mobs too: a mob between the player and
|
||||
// the block is nearer, so the hit is not a block and the use releases
|
||||
// the same way. A guest acts on its client's targeted block, whose view
|
||||
// we cannot trace here, so only the local aim runs the entity test.
|
||||
if self.forced_hit.is_none()
|
||||
&& let Some((eye, _)) = self.aim()
|
||||
&& let Some((_, mob, _)) = self.mob_hit_within(self.block_reach)
|
||||
{
|
||||
let block = (
|
||||
hit.point[0] - eye[0],
|
||||
hit.point[1] - eye[1],
|
||||
hit.point[2] - eye[2],
|
||||
);
|
||||
if mob <= (block.0 * block.0 + block.1 * block.1 + block.2 * block.2).sqrt() {
|
||||
self.stop_using_item(stack);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let elapsed = ticks + 1;
|
||||
// The last tick before the backswing.
|
||||
if elapsed % 10 == 5 {
|
||||
self.brush_stroke(&hit, slot);
|
||||
}
|
||||
// The use ends after 200 ticks; the held button starts it again.
|
||||
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
|
||||
// The use ends after USE_DURATION; stopUsingItem causes the vibration
|
||||
// (vanilla's tickUsingItem), the held button starts it again.
|
||||
if elapsed < USE_DURATION {
|
||||
self.using = Some((slot, elapsed));
|
||||
} else {
|
||||
self.stop_using_item(stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// One stroke on the block aimed at: dust (this window only), the brush
|
||||
|
||||
@@ -252,6 +252,7 @@ mod tests {
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[20.5, 64.0, 0.5],
|
||||
|
||||
@@ -257,6 +257,76 @@ impl MapData {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn crafting_map_takes_use_real_saved_maps() {
|
||||
use crate::game::{Audio, Options, Settings, load_resources};
|
||||
use horizon_client::menu::{Env, Input, Kind, Menu};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&crate::game::Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
for mode in [Input::Swap, Input::Pickup, Input::QuickMove] {
|
||||
let source = game.new_map_id(MapData::fresh(4.5, 4.5, 0, "minecraft:overworld"));
|
||||
let mut input = ItemStack::new("minecraft:filled_map", 1);
|
||||
input.data_mut().map_id = Some(source);
|
||||
let maps = game.level_maps([&input]);
|
||||
let env = Env {
|
||||
maps: Some(&maps),
|
||||
..game.menu_env()
|
||||
};
|
||||
let mut menu = Menu::new(Kind::Crafting);
|
||||
for i in [1, 2, 3, 4, 6, 7, 8, 9] {
|
||||
menu.set_slot(&env, i, Some(ItemStack::new("minecraft:paper", 1)));
|
||||
}
|
||||
menu.set_slot(&env, 5, Some(input));
|
||||
let preview = menu.slot(0).expect("real scaling recipe admitted");
|
||||
assert_eq!(preview.data().and_then(|d| d.map_id), Some(source));
|
||||
assert_eq!(preview.data().and_then(|d| d.map_post_processing), Some(1));
|
||||
menu.click(&env, 0, if mode == Input::Swap { 2 } else { 0 }, mode)
|
||||
.unwrap();
|
||||
let output = match mode {
|
||||
Input::Swap => menu.player_item(2),
|
||||
Input::Pickup => menu.carried.as_ref(),
|
||||
_ => menu.player_item(8),
|
||||
}
|
||||
.expect("crafted map remains present");
|
||||
assert_eq!(output.count, 1);
|
||||
assert_eq!(output.data().and_then(|d| d.map_id), Some(source + 1));
|
||||
assert_eq!(output.data().and_then(|d| d.map_post_processing), None);
|
||||
assert!((1..10).all(|i| menu.slot(i).is_none()));
|
||||
game.register_made_maps(maps);
|
||||
let original = game.map(source).unwrap();
|
||||
assert_eq!((original.scale, original.center), (0, [0, 0]));
|
||||
let derived = game.map(source + 1).unwrap();
|
||||
assert_eq!((derived.scale, derived.center), (1, [64, 64]));
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use crate::game::maps::{Decoration, Key};
|
||||
|
||||
|
||||
@@ -720,10 +720,13 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.filter(|d| !command || d.command)
|
||||
.map(|d| d.text.clone());
|
||||
self.open_chat(draft.as_deref().unwrap_or(prefix));
|
||||
self.open_chat(draft.as_deref().unwrap_or(prefix), draft.is_some());
|
||||
self.ui.chat_draft_shown = draft.is_some() && self.ui.chat_screen.is_some();
|
||||
}
|
||||
pub(super) fn open_chat(&mut self, initial: &str) {
|
||||
/// Open the chat box on `initial`; `is_draft` when that text is the saved
|
||||
/// draft (`ChatComponent.createScreen`), which draws grey italic until it
|
||||
/// is edited.
|
||||
pub(super) fn open_chat(&mut self, initial: &str, is_draft: bool) {
|
||||
if self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -743,6 +746,7 @@ impl Game<'_> {
|
||||
&m,
|
||||
&c,
|
||||
initial,
|
||||
is_draft,
|
||||
self.ui.chat.recent.len(),
|
||||
b.width as i32,
|
||||
b.height as i32,
|
||||
@@ -757,6 +761,36 @@ impl Game<'_> {
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
}
|
||||
/// The open chat box's text and its cursor, counted in characters; `None`
|
||||
/// while the chat screen isn't open. API @API:chat_input@.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_chat_input(&self) -> Option<(String, usize)> {
|
||||
let screen = self.ui.chat_screen.as_ref()?;
|
||||
Some((screen.input.value.clone(), screen.input.cursor))
|
||||
}
|
||||
/// Replaces the open chat box's text and puts the cursor at `cursor`
|
||||
/// (characters, clamped to the text), as an edit: completions refresh as
|
||||
/// they do for typing. What typing can't put in the box isn't put there
|
||||
/// either (`chat_box_text`), and the 256-character limit holds. An error
|
||||
/// while the chat screen isn't open. API @API:chat_input@.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_set_chat_input(
|
||||
&mut self,
|
||||
text: &str,
|
||||
cursor: usize,
|
||||
) -> Result<(), String> {
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let Some(screen) = self.ui.chat_screen.as_mut() else {
|
||||
return Err("the chat isn't open".into());
|
||||
};
|
||||
screen.suggest_command(&m, &c, &chat_box_text(text));
|
||||
screen.input.move_cursor_to(&m, cursor, false);
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn close_chat(&mut self, reason: ChatExit) {
|
||||
// InBedChatScreen.onClose: leaving the screen means getting up; the
|
||||
// screen goes once the player has woken (`tick_in_bed_chat`).
|
||||
@@ -836,11 +870,13 @@ impl Game<'_> {
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.close_chat(ChatExit::Interrupted);
|
||||
if !text.is_empty() {
|
||||
self.open_chat(&text);
|
||||
// InBedChatScreen.onPlayerWokeUp: a new ChatScreen with
|
||||
// the typed text, not a draft.
|
||||
self.open_chat(&text, false);
|
||||
}
|
||||
}
|
||||
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
|
||||
self.open_chat("");
|
||||
self.open_chat("", false);
|
||||
self.ui.in_bed_chat = self.screen == Screen::Chat;
|
||||
}
|
||||
}
|
||||
@@ -1627,23 +1663,23 @@ impl Game<'_> {
|
||||
pub(super) fn toggle_hide_gui(&mut self) {
|
||||
self.ui.hide_gui = !self.ui.hide_gui;
|
||||
}
|
||||
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name.
|
||||
pub(super) fn take_screenshot(&mut self) {
|
||||
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name. The
|
||||
/// file it will write, `None` without a window to draw.
|
||||
pub(super) fn take_screenshot(&mut self) -> Option<std::path::PathBuf> {
|
||||
let dir = horizon_client::logging::player_dir().join("screenshots");
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let name = screenshot_name(&dir);
|
||||
let Some(renderer) = self.renderer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let renderer = self.renderer.as_mut()?;
|
||||
let path = dir.join(name);
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: true,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
self.ui.screenshot_message = Some(path.clone());
|
||||
Some(path)
|
||||
}
|
||||
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
|
||||
/// `key.screenshot` mapping (F2 by default) takes the picture before a
|
||||
@@ -1699,6 +1735,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, in local time).
|
||||
/// What a mod may write into the chat box: the characters `ChatScreen`'s
|
||||
/// typing lets in (`StringUtil.isAllowedChatCharacter`: no control
|
||||
/// characters, no DEL, no section sign).
|
||||
fn chat_box_text(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
|
||||
.collect()
|
||||
}
|
||||
pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
// Util.getFilenameFormattedDateTime: the local date and time.
|
||||
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
|
||||
@@ -1730,6 +1774,17 @@ const LINK_COPY: usize = 1;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A mod's text reaches the chat box as typing would leave it: emoji and
|
||||
/// accents stay, control characters, DEL and the section sign go.
|
||||
#[test]
|
||||
fn a_mods_chat_text_keeps_what_typing_allows() {
|
||||
assert_eq!(
|
||||
super::chat_box_text("hi :smile: → 😀 é"),
|
||||
"hi :smile: → 😀 é"
|
||||
);
|
||||
assert_eq!(super::chat_box_text("a\nb\tc\u{7f}d§6e"), "abcd6e");
|
||||
}
|
||||
|
||||
/// `ChatScreen.shouldDiscardDraft`.
|
||||
#[test]
|
||||
fn draft_discard_follows_the_exit_reason() {
|
||||
@@ -2057,6 +2112,7 @@ mod tests {
|
||||
&m,
|
||||
&c,
|
||||
"",
|
||||
false,
|
||||
0,
|
||||
width as i32,
|
||||
height as i32,
|
||||
|
||||
@@ -599,6 +599,47 @@ impl Game<'_> {
|
||||
}
|
||||
// Java produced the fixture file before this world was opened; map_state loads it.
|
||||
"crafter_max_map_fixture" => {}
|
||||
"boat_interact" => {
|
||||
let uuid = u128::from_str_radix(
|
||||
&op["uuid"]
|
||||
.as_str()
|
||||
.ok_or("boat click: missing UUID")?
|
||||
.replace('-', ""),
|
||||
16,
|
||||
)?;
|
||||
self.in_owner_level(|g| -> Result<()> {
|
||||
let id = g
|
||||
.boat_by_uuid(horizon_client::lan::playerdata::uuid_ints(uuid))
|
||||
.ok_or("boat click: missing live boat")?;
|
||||
let owner = g.serving.as_ref().ok_or("boat click: no server")?.owner;
|
||||
let conn = g
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|p| p.uuid == owner && p.placed)
|
||||
.map(|p| p.conn)
|
||||
})
|
||||
.ok_or("boat click: owner not connected")?;
|
||||
let mut host = g.lan.take().ok_or("boat click: no hub")?;
|
||||
g.lan_guest_packet(
|
||||
&mut host,
|
||||
conn,
|
||||
horizon_protocol::packets::play::Serverbound::Interact(
|
||||
horizon_protocol::packets::play::Interact {
|
||||
id: id as i32,
|
||||
hand: 0,
|
||||
location: [0.0; 3],
|
||||
secondary: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
g.lan = Some(host);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"use_item" => {
|
||||
let owner = self
|
||||
.serving
|
||||
@@ -685,6 +726,100 @@ impl Game<'_> {
|
||||
);
|
||||
out.push('\n');
|
||||
}
|
||||
"craft_menu_click" | "craft_menu_state" | "craft_menu_close" => {
|
||||
let owner = self.serving.as_ref().ok_or("no server")?.owner;
|
||||
let state = self.in_owner_level(|g| -> Result<Value> {
|
||||
let conn = g.lan.as_ref().and_then(|h| h.hub.guests.iter()
|
||||
.find(|p| p.uuid == owner && p.placed).map(|p| p.conn))
|
||||
.ok_or("craft fixture: no connected owner")?;
|
||||
let mut host = g.lan.take().ok_or("no hub")?;
|
||||
let result = (|| -> Result<Value> {
|
||||
let (window, revision, _, _) = host.play.get(&conn)
|
||||
.and_then(|p| p.crafting.table.as_ref())
|
||||
.ok_or("craft fixture: crafting menu not open")?;
|
||||
let (window, revision) = (*window, *revision);
|
||||
if op["op"] == "craft_menu_click" {
|
||||
// Revision last sent by the real table-content sync, never a fixed 0.
|
||||
eprintln!("craft fixture click: window={window}, revision={revision}");
|
||||
g.lan_guest_packet(&mut host, conn,
|
||||
horizon_protocol::packets::play::Serverbound::ContainerClick(
|
||||
horizon_protocol::packets::play::ContainerClick {
|
||||
container: window, state: revision,
|
||||
slot: op["slot"].as_i64().ok_or("click slot")? as i16,
|
||||
button: op["button"].as_i64().unwrap_or(0) as i8,
|
||||
input: op["input"].as_i64().unwrap_or(0) as i32,
|
||||
changed: Vec::new(), carried: None,
|
||||
}));
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if op["op"] == "craft_menu_close" {
|
||||
g.lan_guest_packet(&mut host, conn,
|
||||
horizon_protocol::packets::play::Serverbound::ContainerClose(
|
||||
horizon_protocol::packets::play::ContainerClose { container: window }));
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let (_, _, menu, _) = host.play.get(&conn)
|
||||
.and_then(|p| p.crafting.table.as_ref()).unwrap();
|
||||
if menu.kind() != horizon_client::menu::Kind::Crafting {
|
||||
return Err("craft fixture: wrong menu kind".into());
|
||||
}
|
||||
let guest = host.hub.guest(conn).ok_or("no guest")?;
|
||||
let stack = |s: Option<&horizon_client::items::ItemStack>| {
|
||||
s.map(|s| json!([&*s.item, s.count,
|
||||
s.data().and_then(|d| d.map_id),
|
||||
s.data().and_then(|d| d.map_post_processing)]))
|
||||
};
|
||||
// Table's ten slots plus the owner's live inventory in menu order.
|
||||
let slots: Vec<_> = (0..10).map(|i| stack(menu.slot(i)))
|
||||
.chain((9..36).chain(0..9).map(|i| stack(guest.inventory[i].as_ref())))
|
||||
.collect();
|
||||
Ok(json!({"window": window, "kind": "minecraft:crafting",
|
||||
"slots": slots, "carried": stack(guest.carried.as_ref()).unwrap_or_else(|| json!([]))}))
|
||||
})();
|
||||
g.lan = Some(host);
|
||||
result
|
||||
})?;
|
||||
if op["op"] == "craft_menu_state" {
|
||||
let record = json!({"cmd": format!("#craft_menu {}", op["label"].as_str().unwrap_or("")),
|
||||
"state": state, "messages": [], "results": []});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"map_files" => {
|
||||
let state = self.harness_owner(|g| {
|
||||
use horizon_world::nbt::Tag;
|
||||
g.save_maps();
|
||||
let directory = g.world_root.join("data/minecraft/maps");
|
||||
let maps: Vec<_> = op["ids"].as_array().ok_or("map IDs")?.iter().map(|id| {
|
||||
let path = directory.join(format!("{}.dat", id.as_i64().unwrap()));
|
||||
horizon_client::save::read_dat(&path).map(|root| {
|
||||
let data = root.get("data").unwrap_or(&root);
|
||||
let number = |key| match data.get(key) {
|
||||
Some(Tag::Int(v)) => *v, Some(Tag::Byte(v)) => i32::from(*v), _ => 0,
|
||||
};
|
||||
let colors = match data.get("colors") {
|
||||
Some(Tag::ByteArray(bytes)) => Some(bytes.len()), _ => None,
|
||||
};
|
||||
json!({"scale": number("scale"), "locked": number("locked") != 0,
|
||||
"center": [number("xCenter"), number("zCenter")],
|
||||
"dimension": horizon_client::save::text(data.get("dimension")),
|
||||
"byte_array_length": colors})
|
||||
})
|
||||
}).collect();
|
||||
let last = horizon_client::save::read_dat(&directory.join("last_id.dat"))
|
||||
.and_then(|tag| match tag.get("data").and_then(|d| d.get("map")) {
|
||||
Some(Tag::Int(v)) => Some(*v), _ => None,
|
||||
});
|
||||
Ok(json!({"maps": maps, "last_id": last}))
|
||||
})?;
|
||||
out.push_str(
|
||||
&json!({"cmd": "#map_files", "state": state,
|
||||
"messages": [], "results": []})
|
||||
.to_string(),
|
||||
);
|
||||
out.push('\n');
|
||||
}
|
||||
"map_state" => {
|
||||
let id = op["id"].as_i64().ok_or("command harness: map id")? as i32;
|
||||
let value = self.harness_owner(|g| {
|
||||
@@ -718,6 +853,118 @@ impl Game<'_> {
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"merchant_saved_stats" => {
|
||||
let (state, raw) = self.harness_owner(|g| {
|
||||
g.load_stats();
|
||||
if !g.stats.loaded { return Err("merchant stats not loaded".into()); }
|
||||
g.save_stats();
|
||||
let uuid = g.level.player_uuid.ok_or("merchant stats UUID")?;
|
||||
let path = super::stats::player_json(&g.world_root, "stats", uuid);
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
let saved: Value = serde_json::from_str(&raw)?;
|
||||
let value = |ty: &str, key: &str| saved["stats"][ty][key].as_i64().unwrap_or(0);
|
||||
Ok((json!({"uuid":uuid,"books":value("minecraft:crafted","minecraft:bookshelf"),
|
||||
"glass":value("minecraft:crafted","minecraft:glass"),
|
||||
"trades":value("minecraft:custom","minecraft:traded_with_villager")}), raw))
|
||||
})?;
|
||||
let label = op["label"].as_str().unwrap_or("");
|
||||
std::fs::write(
|
||||
output.with_file_name(format!("native-merchant-stats-{label}.json")),
|
||||
raw,
|
||||
)?;
|
||||
out.push_str(
|
||||
&json!({"cmd":format!("#merchant_saved_stats {label}"),
|
||||
"messages":[],"results":[],"state":state})
|
||||
.to_string(),
|
||||
);
|
||||
out.push('\n');
|
||||
}
|
||||
"merchant_action" | "merchant_state" => {
|
||||
let observe = op["op"] == "merchant_state";
|
||||
let state = self.in_owner_level(|g| -> Result<Value> {
|
||||
let mut host = g.lan.take().ok_or("merchant oracle: no hub")?;
|
||||
let result = (|| -> Result<Value> {
|
||||
let owner = g
|
||||
.serving
|
||||
.as_ref()
|
||||
.ok_or("merchant oracle: no server")?
|
||||
.owner;
|
||||
let conn = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|p| p.uuid == owner && p.placed)
|
||||
.ok_or("merchant oracle: owner not connected")?
|
||||
.conn;
|
||||
if observe {
|
||||
return Ok(g
|
||||
.harness_merchant(&host, conn)
|
||||
.ok_or("merchant oracle: no live menu")?
|
||||
.2);
|
||||
}
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
let packet = match op["action"].as_str().unwrap_or("") {
|
||||
"open" => {
|
||||
let uuid = u128::from_str_radix(
|
||||
op["uuid"]
|
||||
.as_str()
|
||||
.ok_or("merchant oracle: UUID")?
|
||||
.replace('-', "")
|
||||
.as_str(),
|
||||
16,
|
||||
)?;
|
||||
let Some(super::commands::Target::Mob(id)) =
|
||||
g.target_by_uuid(uuid)
|
||||
else {
|
||||
return Err("merchant oracle: missing live mob".into());
|
||||
};
|
||||
S::Interact(play::Interact {
|
||||
id: id as i32,
|
||||
hand: 0,
|
||||
location: [0.0; 3],
|
||||
secondary: false,
|
||||
})
|
||||
}
|
||||
"select" => S::SelectTrade(play::SelectTrade {
|
||||
item: op["index"].as_i64().unwrap_or(0) as i32,
|
||||
}),
|
||||
"click" => {
|
||||
let (container, state, _) = g
|
||||
.harness_merchant(&host, conn)
|
||||
.ok_or("merchant oracle: no menu")?;
|
||||
S::ContainerClick(play::ContainerClick {
|
||||
container,
|
||||
state,
|
||||
slot: op["slot"].as_i64().ok_or("merchant oracle: slot")?
|
||||
as i16,
|
||||
button: op["button"].as_i64().unwrap_or(0) as i8,
|
||||
input: op["input"].as_i64().unwrap_or(0) as i32,
|
||||
changed: Vec::new(),
|
||||
carried: None,
|
||||
})
|
||||
}
|
||||
"close" => S::ContainerClose(play::ContainerClose {
|
||||
container: g
|
||||
.harness_merchant(&host, conn)
|
||||
.ok_or("merchant oracle: no menu")?
|
||||
.0,
|
||||
}),
|
||||
_ => return Err("merchant oracle: unknown action".into()),
|
||||
};
|
||||
eprintln!("merchant action receipt: action={} owner={owner:032x} conn={conn} menu={:?} clock={}",
|
||||
op["action"], g.harness_merchant(&host, conn).map(|(w, r, _)| (w, r)), g.world.game_time);
|
||||
g.lan_guest_packet(&mut host, conn, packet);
|
||||
Ok(Value::Null)
|
||||
})();
|
||||
g.lan = Some(host);
|
||||
result
|
||||
})?;
|
||||
if observe {
|
||||
out.push_str(&json!({"cmd":format!("#merchant_state {}",op["label"].as_str().unwrap_or("")),
|
||||
"messages":[],"results":[],"state":state}).to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"menu_state" => {
|
||||
let (_, own_slots, _) = self.harness_owner_menu()?;
|
||||
let record = json!({"cmd": format!("#menu_state {}", op["label"].as_str().unwrap_or("")),
|
||||
|
||||
@@ -906,7 +906,7 @@ impl Game<'_> {
|
||||
/// the entity lists (a kind added there joins `@e` once it names its
|
||||
/// `Target`), then the kinds that are not on the walk. An
|
||||
/// unsorted selector lists them in that order.
|
||||
fn level_candidates(&self) -> Vec<Target> {
|
||||
pub(in crate::game) fn level_candidates(&self) -> Vec<Target> {
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
|
||||
let mut candidates = Vec::new();
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
@@ -1559,6 +1559,7 @@ impl Game<'_> {
|
||||
// there at once: the arrival they were waiting for elsewhere
|
||||
// in it (whose chunks lose their tickets) is abandoned.
|
||||
if let Some(conn) = self.lan_acting {
|
||||
horizon_client::lan::hub::teleport_cause(Some("command"));
|
||||
self.server_trips.retain(|trip| !trip.is(conn));
|
||||
}
|
||||
let from = self
|
||||
@@ -1816,6 +1817,7 @@ impl Game<'_> {
|
||||
at: pos,
|
||||
rotation,
|
||||
vehicle: None,
|
||||
cause: Some("command"),
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
@@ -4009,6 +4011,7 @@ impl Game<'_> {
|
||||
));
|
||||
Ok(count as i32)
|
||||
}
|
||||
/// `SayCommand.register`: resolve the source name and broadcast unsigned announcements.
|
||||
fn cmd_say(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let raw = match ctx.get(if id == "me" { "action" } else { "message" }) {
|
||||
Some(Arg::Str(s)) => s.clone(),
|
||||
@@ -4016,13 +4019,47 @@ impl Game<'_> {
|
||||
};
|
||||
let message = self.expand_selectors(&raw);
|
||||
// CommandSourceStack.getDisplayName: the executing entity's name.
|
||||
let name = match self.command_source.map(|s| s.entity) {
|
||||
Some(Some(t)) => self.target_name(t),
|
||||
Some(None) => Text::literal("Server"),
|
||||
let name = match self.command_source {
|
||||
Some(source) => match source.entity {
|
||||
Some(target) => self.target_name(target),
|
||||
None => source
|
||||
.block
|
||||
.and_then(|pos| match self.world.block_entities.get(&pos) {
|
||||
Some(horizon_client::containers::BlockEntity::CommandBlock(block)) => {
|
||||
Some(block.name())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.or_else(|| {
|
||||
source.cart.and_then(|id| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|cart| cart.id == id)
|
||||
.map(|cart| cart.command.name())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| Text::literal("Server")),
|
||||
},
|
||||
None => self.player_display_name(),
|
||||
};
|
||||
// A player's command whose output is captured (a function, `execute …
|
||||
// run say`, the command harness) gets the announcement with that
|
||||
// output, in order; everyone else gets the broadcast.
|
||||
let captured_for = self.chat_capture.as_ref().and(self.lan_acting);
|
||||
let line = match id {
|
||||
"say" => Text::translate("chat.type.announcement", &[name, message]),
|
||||
"say" => {
|
||||
// SayCommand broadcasts independently of the command source's feedback.
|
||||
if let Some(host) = &self.lan {
|
||||
use horizon_client::lan::data::component_nbt;
|
||||
host.hub.broadcast_source_chat(
|
||||
"say_command",
|
||||
component_nbt(&name.component()),
|
||||
component_nbt(&message.component()),
|
||||
captured_for,
|
||||
);
|
||||
}
|
||||
Text::translate("chat.type.announcement", &[name, message])
|
||||
}
|
||||
"me" => Text::translate("chat.type.emote", &[name, message]),
|
||||
_ => {
|
||||
let _ = self.entities(ctx, "targets", true)?;
|
||||
@@ -4042,6 +4079,27 @@ impl Game<'_> {
|
||||
.italic()
|
||||
}
|
||||
};
|
||||
if id == "say" {
|
||||
if self.lan.is_some() && self.options.dedicated.is_some() {
|
||||
self.lan_log_event(horizon_client::lan::hub::HostEvent::Chat {
|
||||
line: line.plain(),
|
||||
secure: false,
|
||||
});
|
||||
}
|
||||
if self.lan.is_some() && self.chat_capture.is_some() {
|
||||
// The broadcast skipped the acting player: their copy goes with
|
||||
// the captured output. Without one (the console), the broadcast
|
||||
// reached everyone and the console logged it above.
|
||||
if captured_for.is_some()
|
||||
&& let Some(capture) = self.chat_capture.as_mut()
|
||||
{
|
||||
capture.push(line);
|
||||
}
|
||||
return Ok(1);
|
||||
}
|
||||
self.player_message(line);
|
||||
return Ok(1);
|
||||
}
|
||||
if let Some(capture) = self.chat_capture.as_mut() {
|
||||
capture.push(line.clone());
|
||||
}
|
||||
|
||||
@@ -136,8 +136,9 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Boat(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = self.boats.iter().find(|b| b.id == id)?;
|
||||
entity_nbt::boat_to_nbt(
|
||||
let i = self.boats.iter().position(|b| b.id == id)?;
|
||||
let b = &self.boats[i];
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
@@ -145,7 +146,10 @@ impl Game<'_> {
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
)
|
||||
);
|
||||
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
|
||||
self.write_boat_leash(&mut tag, i);
|
||||
tag
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
|
||||
@@ -65,8 +65,9 @@ impl Game<'_> {
|
||||
for t in self.living_targets(ctx)? {
|
||||
let changed = match t {
|
||||
Target::Player | Target::Guest(_) => {
|
||||
let mut instance = EffectInstance::new(effect.id, duration, amplifier);
|
||||
instance.visible = particles;
|
||||
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
|
||||
let instance = EffectInstance::new(effect.id, duration, amplifier)
|
||||
.with_flags(false, particles);
|
||||
self.for_player(t, |g| g.add_player_effect(instance))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -77,6 +78,14 @@ impl Game<'_> {
|
||||
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
|
||||
let changed =
|
||||
m.add_effect_with(name, duration, amplifier, false, particles);
|
||||
// MobEffect.onEffectAdded: an omen's sound, at the mob's feet.
|
||||
if new
|
||||
&& changed
|
||||
&& let Some(sound) = super::super::potions::sound_on_added(name)
|
||||
{
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.mod_effect_added(name, Some(id as i32), amplifier, new);
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -1156,7 +1156,7 @@ impl Game<'_> {
|
||||
Ok((true, count))
|
||||
}
|
||||
/// The biome name at a block position.
|
||||
fn biome_name_at_block(&self, p: [i32; 3]) -> Option<String> {
|
||||
pub(in crate::game) fn biome_name_at_block(&self, p: [i32; 3]) -> Option<String> {
|
||||
let chunk = self.world.chunks.get(&(p[0] >> 4, p[2] >> 4))?;
|
||||
// The world's table (`world_biomes!`): a packed biome's id is past
|
||||
// the built-in list, and `Biomes::get` would clamp it to the last
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! `RaidCommand`: `/raid start|stop|check|spawnleader|setomen|glow|sound`.
|
||||
use super::{Game, R, Text, err};
|
||||
use horizon_client::{command::Context, mobs::raider};
|
||||
use horizon_client::{
|
||||
command::{Arg, Context},
|
||||
mobs::raider,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(in crate::game) fn cmd_raid(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
@@ -39,7 +43,9 @@ impl Game<'_> {
|
||||
Ok(-1)
|
||||
}
|
||||
},
|
||||
"raid.check" => match self.raiding_nearby(at).filter(|id| self.raid_started(*id)) {
|
||||
// RaidCommand.getRaid is getRaidAt: a raid still counting down
|
||||
// to its first wave is found too (#11372).
|
||||
"raid.check" => match self.raiding_nearby(at) {
|
||||
Some(raid) => {
|
||||
self.feedback(Text::literal("Found a started raid!"));
|
||||
let status = self.raid_status_text(raid);
|
||||
@@ -96,13 +102,90 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
// RaidCommand.playSound: only for the text "local", at volume 2,
|
||||
// with a seed from the level's random (playSeededSound).
|
||||
"raid.sound" => {
|
||||
let at = [player.x + 5.0, player.y, player.z];
|
||||
self.audio
|
||||
.play("minecraft:event.raid.horn", Some(at), 1.0, 1.0);
|
||||
// ComponentArgument: `local`, "local" and 'local' are all the
|
||||
// text "local".
|
||||
let kind = match ctx.get("type") {
|
||||
Some(Arg::Str(s)) => s.trim(),
|
||||
_ => "",
|
||||
};
|
||||
if matches!(kind, "local" | "\"local\"" | "'local'") {
|
||||
let seed = self.world.random.next_long();
|
||||
let at = [player.x + 5.0, player.y, player.z];
|
||||
self.audio
|
||||
.play_seeded("minecraft:event.raid.horn", Some(at), 2.0, 1.0, seed);
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
_ => Err(err("commands.help.failed", &[])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Game;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
use horizon_client::{command::vanilla, motion::PlayerMotion};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// Runs a line of the dev tree (`/raid` is registered only there).
|
||||
fn run_dev(game: &mut Game<'_>, line: &str) -> i32 {
|
||||
let d = vanilla::build_with(true);
|
||||
let parse = d.parse(line, &game.ui.registries);
|
||||
let (ctx, _) = parse.executable().expect(line);
|
||||
let (ctx, root) = (ctx.clone(), parse.context.clone());
|
||||
game.run_chain(&root, &ctx).expect(line)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn raid_check_finds_a_raid_counting_down_and_the_horn_is_local_only() {
|
||||
let mut game = server_game();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
|
||||
// RaidCommand needs the owner's permission level (3).
|
||||
game.ui.registries.permission = 4;
|
||||
game.chat_capture = Some(Vec::new());
|
||||
game.audio.capture = Some(Vec::new());
|
||||
let (id, _) = game.raiding.raids.create_or_extend([0, 64, 0], 2, None, 3);
|
||||
assert!(!game.raiding.raids.get(id).unwrap().started);
|
||||
|
||||
assert_eq!(run_dev(&mut game, "raid check"), 1);
|
||||
let lines: Vec<String> = game
|
||||
.chat_capture
|
||||
.take()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t.plain())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
"Found a started raid!",
|
||||
"Num groups spawned: 0 Raid omen level: 0 Num mobs: 0 Raid health: 0.0 / 0.0",
|
||||
]
|
||||
);
|
||||
|
||||
run_dev(&mut game, "raid sound global");
|
||||
assert!(game.audio.capture.as_ref().unwrap().is_empty());
|
||||
run_dev(&mut game, "raid sound local");
|
||||
let played: Vec<_> = game
|
||||
.audio
|
||||
.capture
|
||||
.take()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| (s.event, s.position, s.volume))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
played,
|
||||
[(
|
||||
"minecraft:event.raid.horn".to_owned(),
|
||||
[5.5, 64.0, 0.5],
|
||||
2.0
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -195,9 +195,37 @@ impl Game<'_> {
|
||||
_ => by.and_then(|t| self.target_position(t)),
|
||||
};
|
||||
// Destruction can remove the target before its success message is built.
|
||||
let discarded_name = matches!(target, Target::Item(_) | Target::Boat(_) | Target::Cart(_))
|
||||
let item_name = matches!(target, Target::Item(_) | Target::Boat(_) | Target::Cart(_))
|
||||
.then(|| self.target_name(target));
|
||||
let hurt = match target {
|
||||
let hurt = self.damage_target(target, amount, &ty, cause, by, from);
|
||||
if !hurt {
|
||||
return Err(err("commands.damage.invulnerable", &[]));
|
||||
}
|
||||
let name = item_name.unwrap_or_else(|| self.target_name(target));
|
||||
self.feedback(Text::translate(
|
||||
"commands.damage.success",
|
||||
&[
|
||||
Text::literal(horizon_client::snbt::java_float(amount)),
|
||||
name,
|
||||
],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `DamageCommand.damage`'s hurt: `target` takes `amount` of `ty` (`minecraft:mob_attack`)
|
||||
/// from the causing entity `cause` and the direct one `by` (the causing one is either when
|
||||
/// the other is none), from `from` (the knockback's direction): whether it was hurt. Also
|
||||
/// `native_hurt_entity`'s.
|
||||
pub(in crate::game) fn damage_target(
|
||||
&mut self,
|
||||
target: Target,
|
||||
amount: f32,
|
||||
ty: &str,
|
||||
cause: Option<Target>,
|
||||
by: Option<Target>,
|
||||
from: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
match target {
|
||||
// The causing mob is the one the hit is credited to and thorns
|
||||
// answer; the source position gives the knockback its direction.
|
||||
Target::Player | Target::Guest(_) => {
|
||||
@@ -217,9 +245,9 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if let Some(name) = named {
|
||||
g.note_direct_entity(&ty, name);
|
||||
g.note_direct_entity(ty, name);
|
||||
}
|
||||
g.hurt_player_from(amount, from, &ty, attacker)
|
||||
g.hurt_player_from(amount, from, ty, attacker)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -234,7 +262,7 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
let damage = Damage {
|
||||
kind: Self::damage_kind(&ty),
|
||||
kind: Self::damage_kind(ty),
|
||||
attacker,
|
||||
from,
|
||||
attacker_species,
|
||||
@@ -264,15 +292,15 @@ impl Game<'_> {
|
||||
self.command_damage_mob(id, amount, damage)
|
||||
}
|
||||
}
|
||||
Target::Item(id) => self.command_damage_item(id, amount, &ty, cause.or(by)),
|
||||
Target::Item(id) => self.command_damage_item(id, amount, ty, cause.or(by)),
|
||||
Target::Boat(_) | Target::Cart(_) => {
|
||||
self.command_damage_vehicle(target, amount, &ty, cause.or(by))
|
||||
self.command_damage_vehicle(target, amount, ty, cause.or(by))
|
||||
}
|
||||
Target::Dragon(id) => self.command_damage_dragon(
|
||||
id,
|
||||
amount,
|
||||
&ty,
|
||||
Self::damage_kind(&ty),
|
||||
ty,
|
||||
Self::damage_kind(ty),
|
||||
(
|
||||
by,
|
||||
cause.or(by),
|
||||
@@ -280,22 +308,10 @@ impl Game<'_> {
|
||||
),
|
||||
),
|
||||
Target::Crystal(id) => {
|
||||
self.command_damage_crystal(id, &ty, Self::damage_kind(&ty), cause.or(by))
|
||||
self.command_damage_crystal(id, ty, Self::damage_kind(ty), cause.or(by))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !hurt {
|
||||
return Err(err("commands.damage.invulnerable", &[]));
|
||||
}
|
||||
let name = discarded_name.unwrap_or_else(|| self.target_name(target));
|
||||
self.feedback(Text::translate(
|
||||
"commands.damage.success",
|
||||
&[
|
||||
Text::literal(horizon_client::snbt::java_float(amount)),
|
||||
name,
|
||||
],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `VehicleEntity.hurtServer`: report acceptance even when the vehicle survives.
|
||||
@@ -1509,6 +1525,20 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let vehicle_name = self.target_name(vehicle);
|
||||
self.ride_mount(target, vehicle)?;
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `RideCommand.mount` once the targets are known: `target` (the player
|
||||
/// in place or a mob) rides `vehicle`, as `startRiding(vehicle, true,
|
||||
/// true)` forces it (also for `native_mount`).
|
||||
pub(in crate::game) fn ride_mount(&mut self, target: Target, vehicle: Target) -> R<()> {
|
||||
let name = self.target_name(target);
|
||||
let vehicle_name = self.target_name(vehicle);
|
||||
if let Some(current) = self.ride_vehicle(target) {
|
||||
let current = self.target_name(current);
|
||||
return Err(err(
|
||||
@@ -1574,11 +1604,7 @@ impl Game<'_> {
|
||||
// sendEventAndTriggers: START_RIDING_TRIGGER.
|
||||
self.start_riding(id);
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
return Ok(());
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
if target != Target::Player
|
||||
@@ -1588,11 +1614,7 @@ impl Game<'_> {
|
||||
return Err(generic());
|
||||
}
|
||||
self.start_riding(id);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1608,11 +1630,7 @@ impl Game<'_> {
|
||||
if !self.try_mount_mob(rider, mob_id) {
|
||||
return Err(generic());
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
return Ok(());
|
||||
}
|
||||
if !self.native_may_board(mob_id) {
|
||||
return Err(generic());
|
||||
@@ -1625,11 +1643,7 @@ impl Game<'_> {
|
||||
self.mount.jump_ticks = 0;
|
||||
self.mount.jump_scale = 0.0;
|
||||
self.start_riding(mob_id);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
Ok(1)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entity.getVehicle: the player's mount, or a mob's.
|
||||
|
||||
@@ -386,6 +386,9 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
|
||||
let y = to[1].clamp(f64::from(shape.min_y), f64::from(high));
|
||||
*target = Vec3::new(to[0], y, to[2]);
|
||||
}
|
||||
if !cancel {
|
||||
horizon_client::lan::hub::teleport_cause(Some("chorus_fruit"));
|
||||
}
|
||||
Ok(!cancel)
|
||||
}
|
||||
fn random_teleport(&mut self, target: Vec3) -> Result<bool, Infallible> {
|
||||
|
||||
@@ -260,6 +260,21 @@ impl Game<'_> {
|
||||
/// Nothing here is a key — the caller drops control characters. `Err` when
|
||||
/// no text box of the screen takes them.
|
||||
pub(super) fn type_chars_into_screen(&mut self, text: &str) -> Result<()> {
|
||||
// Overlays own the input even when a text box remains focused behind them.
|
||||
if self.dialog_open() {
|
||||
self.dialog_type(text);
|
||||
return Ok(());
|
||||
}
|
||||
if self.prompt_open() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(result) = self.type_text_field_input(text) {
|
||||
return result;
|
||||
}
|
||||
self.type_chars_unhooked(text)
|
||||
}
|
||||
|
||||
pub(super) fn type_chars_unhooked(&mut self, text: &str) -> Result<()> {
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return Ok(());
|
||||
@@ -1015,6 +1030,12 @@ impl Game<'_> {
|
||||
// key event fires it. A cancelled character is dropped, so
|
||||
// `type` types the survivors only.
|
||||
// `\b` is the socket's backspace sentinel, not two characters.
|
||||
if text != "\\b"
|
||||
&& let Some(result) = self.type_text_field_input(text)
|
||||
{
|
||||
result?;
|
||||
return ok();
|
||||
}
|
||||
let typed = if text == "\\b" {
|
||||
text.to_owned()
|
||||
} else {
|
||||
|
||||
@@ -17,7 +17,7 @@ use horizon_client::{
|
||||
collisions::SceneCollisions,
|
||||
crafting::Recipes,
|
||||
graphics::EntityCullingMode,
|
||||
health::{HurtSource, Hurting, Knock, knockback_canceled},
|
||||
health::{HealthEvent, HurtSource, Hurting, Knock, knockback_canceled},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{
|
||||
@@ -108,6 +108,9 @@ pub(super) struct Creatures {
|
||||
/// The snapshot's buffer between ticks, empty: ~400 bytes a mob, so
|
||||
/// thousands of mobs would allocate and free megabytes every tick.
|
||||
seen_buf: Vec<Seen>,
|
||||
/// The targets the mobs had before their ticks, kept between ticks
|
||||
/// (empty) as `seen_buf` is: `native_living_change_target` hears a change.
|
||||
target_buf: Vec<Option<Who>>,
|
||||
/// The mobs whose own tick ran in the last `tick_creature_entities`:
|
||||
/// `ServerLevel.tick` ticks only the entities in entity-ticking range, so
|
||||
/// what the passes after the loop do for a mob's `aiStep`, goals or brain
|
||||
@@ -124,6 +127,17 @@ pub(super) struct Creatures {
|
||||
/// Particles from a guest's turn, waiting for it to end.
|
||||
pub lan_particles: Vec<super::lan_particles::QueuedParticle>,
|
||||
}
|
||||
/// Whether `p`'s next step goes from inside the world border (`bounds`: [minX, minZ, maxX,
|
||||
/// maxZ]) to outside it, as `clip_including_border` would stop it.
|
||||
fn border_crossing(p: &Projectile, bounds: Option<[f64; 4]>) -> bool {
|
||||
let Some([min_x, min_z, max_x, max_z]) = bounds else {
|
||||
return false;
|
||||
};
|
||||
let within = |x: f64, z: f64| x >= min_x && x < max_x && z >= min_z && z < max_z;
|
||||
let (at, v) = (p.position, p.velocity);
|
||||
within(at.x, at.z) && !within(at.x + v.x, at.z + v.z)
|
||||
}
|
||||
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
@@ -296,6 +310,53 @@ fn flame_geometry(
|
||||
pub(super) fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen {
|
||||
m.seen(player)
|
||||
}
|
||||
/// A mob's target as `native_living_change_target` names it: a player's UUID
|
||||
/// or a mob's id, both None for no target; None for one it cannot name.
|
||||
/// `Who::Player` is the guest the mob sees (`seen`, a connection) or, with
|
||||
/// none, the host's own player (`host`); a guest's entity (`guest_ids`, by
|
||||
/// connection) is that guest.
|
||||
fn target_named(
|
||||
target: Option<Who>,
|
||||
seen: Option<u32>,
|
||||
guests: &super::lan_mobs::GuestViews,
|
||||
guest_ids: &[(u32, u32)],
|
||||
host: Option<&PlayerView>,
|
||||
) -> Option<(Option<u128>, Option<u32>)> {
|
||||
let uuid = |v: &PlayerView| horizon_client::lan::playerdata::ints_uuid(v.uuid);
|
||||
let guest = |conn: u32| guests.iter().find(|g| g.0 == conn).map(|g| uuid(&g.1));
|
||||
match target {
|
||||
None => Some((None, None)),
|
||||
Some(Who::Player) => {
|
||||
let player = match seen {
|
||||
Some(conn) => guest(conn),
|
||||
None => host.map(uuid),
|
||||
};
|
||||
Some((Some(player?), None))
|
||||
}
|
||||
Some(Who::Mob(id)) => match guest_ids.iter().find(|g| g.1 == id) {
|
||||
Some(&(conn, _)) => Some((Some(guest(conn)?), None)),
|
||||
None => Some((None, Some(id))),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Whether `e`, from a mob's tick, is a hit it deals `target`: a melee hit
|
||||
/// (with its effect), a sting, a sonic boom.
|
||||
fn hits_target(e: &MobEvent, want: Who) -> bool {
|
||||
match (e, want) {
|
||||
(
|
||||
MobEvent::AttackPlayer(..)
|
||||
| MobEvent::AttackPlayerWithEffect { .. }
|
||||
| MobEvent::StingPlayer { .. },
|
||||
Who::Player,
|
||||
) => true,
|
||||
(
|
||||
MobEvent::AttackMob { target, .. } | MobEvent::AttackMobWithEffect { target, .. },
|
||||
Who::Mob(id),
|
||||
) => *target == id,
|
||||
(MobEvent::SonicBoom { target, .. }, _) => *target == want,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Whether a mob a packet named may take the click: `mob_hit` skips a mob in
|
||||
/// its death animation, the one the player rides (it shares the player's root
|
||||
/// vehicle) and the camera entity the pick starts from, so a packet that names
|
||||
@@ -1020,6 +1081,11 @@ impl Game<'_> {
|
||||
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
|
||||
let mut events = Vec::new();
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
// Each mob's target as the mods last heard it, by its place in `mobs`
|
||||
// (`native_living_change_target`).
|
||||
let mut targets = std::mem::take(&mut self.creatures.target_buf);
|
||||
targets.clear();
|
||||
targets.extend(mobs.iter().map(|m| m.ai.target));
|
||||
let mut dead = Vec::new();
|
||||
let mut corpses = Vec::new();
|
||||
let mut died = std::mem::take(&mut self.died);
|
||||
@@ -1209,6 +1275,46 @@ impl Game<'_> {
|
||||
} else if let Some(k) = lent {
|
||||
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
|
||||
}
|
||||
// Its tick changed its target: the mods may keep the one it had
|
||||
// (`native_living_change_target`), and then what the tick did to
|
||||
// the new one is not dealt.
|
||||
if let Some(&before) = targets.get(i)
|
||||
&& m.ai.target != before
|
||||
{
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&guests,
|
||||
&guest_ids,
|
||||
base.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Self::native_living_change_target(
|
||||
level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
if let Some(new) = m.ai.target {
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if hits_target(&events[k], new) {
|
||||
events.remove(k);
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
targets[i] = m.ai.target;
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
@@ -1272,6 +1378,43 @@ impl Game<'_> {
|
||||
grid.update(i, snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
}
|
||||
// Targets changed after the mob's own tick (another mob's alert):
|
||||
// heard now, and a cancel puts the old one back before its next tick.
|
||||
for (i, m) in mobs.iter_mut().enumerate() {
|
||||
let Some(&before) = targets.get(i) else {
|
||||
break;
|
||||
};
|
||||
if m.ai.target == before {
|
||||
continue;
|
||||
}
|
||||
let seen_guest = if guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
bindings.get(&m.id).copied()
|
||||
};
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&guests,
|
||||
&guest_ids,
|
||||
base.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Self::native_living_change_target(
|
||||
level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
laps.lap("creature_loop");
|
||||
let mut flags = keep.into_iter();
|
||||
mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
@@ -1303,6 +1446,8 @@ impl Game<'_> {
|
||||
snapshot.clear();
|
||||
self.creatures.seen_buf = snapshot;
|
||||
self.creatures.ticked = ticked;
|
||||
targets.clear();
|
||||
self.creatures.target_buf = targets;
|
||||
self.native_tick_post_batch(round);
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
// 🤖 Agent: the split follows the poof, as `LivingEntity.tickDeath`
|
||||
@@ -2215,6 +2360,15 @@ impl Game<'_> {
|
||||
let ctx = self.who_context(Some(Who::Mob(source)), None);
|
||||
self.post_game_event(event, at, ctx);
|
||||
}
|
||||
MobEvent::GameEvent {
|
||||
event: "minecraft:block_place",
|
||||
at,
|
||||
source,
|
||||
state,
|
||||
} => {
|
||||
let ctx = self.who_context(Some(Who::Mob(source)), Some(state));
|
||||
self.post_game_event("minecraft:block_place", at, ctx);
|
||||
}
|
||||
// The sculk layer tracks steps and bee/bat flaps itself
|
||||
// (`entity_motion_events`, `tick_mob_sculk_events`). Birds
|
||||
// above use their movement's flyDist/nextFlap threshold.
|
||||
@@ -3316,10 +3470,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the
|
||||
/// `mob_drops` game rule is on.
|
||||
/// `mob_drops` game rule is on, and one `new ExperienceOrb` of the whole
|
||||
/// value (not split or merged as `ExperienceOrb.award` does).
|
||||
fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) {
|
||||
if self.ui.rules.bool("mob_drops") {
|
||||
self.spawn_orbs(at, amount);
|
||||
self.add_orb(at, amount);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.tellNeutralMobsThatIDied`: the mobs within 32 blocks
|
||||
@@ -3680,13 +3835,14 @@ impl Game<'_> {
|
||||
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
|
||||
}
|
||||
/// `Animal.finalizeSpawnChildFromBreeding`'s experience, from the breeding
|
||||
/// parent's own random, and only when the `mob_drops` game rule is on.
|
||||
/// parent's own random, and only when the `mob_drops` game rule is on: one
|
||||
/// fresh orb of 1 to 7 (#11388).
|
||||
fn breeding_orb(&mut self, parent: u32, at: [f64; 3]) {
|
||||
if self.ui.rules.bool("mob_drops")
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
|
||||
{
|
||||
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs(at, xp);
|
||||
self.add_orb(at, xp);
|
||||
}
|
||||
}
|
||||
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
|
||||
@@ -3844,6 +4000,7 @@ impl Game<'_> {
|
||||
// The hit's own push, held back until after the shield's (`hurtServer`
|
||||
// runs `applyItemBlocking` before `dealDefaultKnockback`).
|
||||
let mut default_push: Option<([f64; 2], HurtSource<'_>)> = None;
|
||||
let events_before = self.player.as_ref().map_or(0, |p| p.events.len());
|
||||
if let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.health_resources.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_caused_by(
|
||||
@@ -3879,15 +4036,36 @@ impl Game<'_> {
|
||||
default_push = Some(([xd, zd], source));
|
||||
}
|
||||
// The thorns hurt the entity that caused the hit, if there is one.
|
||||
// Thorns' own hit is no attack, so nothing answers it
|
||||
// (`doPostAttackEffects` runs from the attacks, not `hurtServer`).
|
||||
let killed = self.player.as_ref().is_some_and(|p| {
|
||||
super::wither::killed(true, p.health.dead_or_dying(), p.health.totem_used)
|
||||
});
|
||||
if let Some(a) = attacker.or(owner)
|
||||
&& !(post_attack_if_alive && killed)
|
||||
&& damage_type != "minecraft:thorns"
|
||||
{
|
||||
self.thorns(a);
|
||||
}
|
||||
}
|
||||
// LivingEntity.playSecondaryHurtSound: a thorns hit that landed whole
|
||||
// sounds where the player stands, for everyone around.
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& p.events.get(events_before..).is_some_and(|new| {
|
||||
new.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
horizon_client::motion::MotionEvent::Health(HealthEvent::Phase(
|
||||
"thorns-sound"
|
||||
))
|
||||
)
|
||||
})
|
||||
})
|
||||
{
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.audio
|
||||
.play("minecraft:enchant.thorns.hit", Some(at), 1.0, 1.0);
|
||||
}
|
||||
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
|
||||
// early (invulnerable, or inside the hurt cooldown).
|
||||
// A hit a native mod canceled records no damage (hurtServer never ran).
|
||||
@@ -4113,6 +4291,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let border = Some(self.border.bounds());
|
||||
let dimension = self.dim.id();
|
||||
let pw = ProjWorld {
|
||||
w: &w,
|
||||
targets,
|
||||
@@ -4158,6 +4337,23 @@ impl Game<'_> {
|
||||
keep_flags[i] = false;
|
||||
continue;
|
||||
}
|
||||
// MC-310476: the mods may let a thrown projectile through the world border.
|
||||
let through = p.arrow.is_none() && !p.stuck && border_crossing(p, border) && {
|
||||
let (at, v) = (p.position, p.velocity);
|
||||
let (mut pos, mut velocity) = ([at.x, at.y, at.z], [v.x, v.y, v.z]);
|
||||
let mut collide = true;
|
||||
let kind = p.kind.id();
|
||||
let (vel, c) = (&mut velocity, &mut collide);
|
||||
Self::native_projectile_world_border(dimension, p.id, kind, &mut pos, vel, c);
|
||||
if pos.iter().chain(&velocity).all(|v| v.is_finite())
|
||||
&& (pos != [at.x, at.y, at.z] || velocity != [v.x, v.y, v.z])
|
||||
{
|
||||
p.position = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
p.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
p.needs_sync = true;
|
||||
}
|
||||
!collide
|
||||
};
|
||||
// The projectile as it was, to fly on if a mod cancels its impact.
|
||||
let before = (hear && !p.stuck).then(|| p.clone());
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
@@ -4188,6 +4384,16 @@ impl Game<'_> {
|
||||
targets.extend(winds);
|
||||
horizon_client::arrows::tick(p, &scene, &targets)
|
||||
}
|
||||
// Through the border: this tick's ray does not stop on it.
|
||||
(false, with_guests, true) if through => p.tick(&ProjWorld {
|
||||
w: pw.w,
|
||||
targets: if with_guests {
|
||||
pw_guests.targets.clone()
|
||||
} else {
|
||||
pw.targets.clone()
|
||||
},
|
||||
border: None,
|
||||
}),
|
||||
(false, false, true) => p.tick(&pw),
|
||||
(false, true, true) => p.tick(&pw_guests),
|
||||
(false, pvp_shot, false) => {
|
||||
@@ -4200,7 +4406,7 @@ impl Game<'_> {
|
||||
p.tick(&ProjWorld {
|
||||
w: if pvp_shot { pw_guests.w } else { pw.w },
|
||||
targets,
|
||||
border,
|
||||
border: border.filter(|_| !through),
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -4440,6 +4646,23 @@ impl Game<'_> {
|
||||
None => self.projectile_hit(p, hit),
|
||||
}
|
||||
}
|
||||
/// `EnchantmentHelper.doPostAttackEffects` after the player's shot `p`
|
||||
/// (its `direct` entity, a hit of `damage_type`) hurt the mob `who`: the
|
||||
/// victim's own post-attack enchantments answer the player (Thorns).
|
||||
pub(super) fn shot_post_attack(
|
||||
&mut self,
|
||||
p: &Projectile,
|
||||
who: Who,
|
||||
direct: &str,
|
||||
damage_type: &str,
|
||||
) {
|
||||
if p.owner == Some(Who::Player)
|
||||
&& let Who::Mob(id) = who
|
||||
&& let Some(index) = self.mobs.iter().position(|m| m.id == id)
|
||||
{
|
||||
self.victim_post_attack(index, direct, Some(damage_type));
|
||||
}
|
||||
}
|
||||
/// What an arrow or a thrown trident hits an entity of type `victim`
|
||||
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
|
||||
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
|
||||
@@ -4746,6 +4969,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
let result = self.projectile_hit_inner(p, hit);
|
||||
// LivingEntity.actuallyHurt credits the shooter (a ServerPlayer) with the absorbed damage.
|
||||
self.settle_dealt_absorbed(p.owner == Some(Who::Player));
|
||||
result
|
||||
}
|
||||
fn projectile_hit_inner(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
let _attacker = self.knockback_scope_for(p.owner);
|
||||
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
|
||||
// projectile (a wind charge out of the world's top has no face).
|
||||
@@ -4949,6 +5178,7 @@ impl Game<'_> {
|
||||
}),
|
||||
};
|
||||
let outcome = if hurt {
|
||||
self.shot_post_attack(p, who, "minecraft:trident", "minecraft:trident");
|
||||
EntityHit::Hurt
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
@@ -5123,6 +5353,16 @@ impl Game<'_> {
|
||||
}])?;
|
||||
}
|
||||
if hurt {
|
||||
let spectral = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
|
||||
let direct = if spectral {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
"minecraft:arrow"
|
||||
};
|
||||
self.shot_post_attack(p, who, direct, "minecraft:arrow");
|
||||
self.arrow_post_hurt_effects(p, who);
|
||||
}
|
||||
match (hurt, who) {
|
||||
@@ -5327,6 +5567,9 @@ impl Game<'_> {
|
||||
pl.fall_distance = 0.0;
|
||||
pl.teleported = true;
|
||||
}
|
||||
if self.lan_acting.is_some() {
|
||||
horizon_client::lan::hub::teleport_cause(Some("ender_pearl"));
|
||||
}
|
||||
self.previous_position = to;
|
||||
if damage > 0.0 {
|
||||
self.hurt_player_by("minecraft:ender_pearl", damage);
|
||||
@@ -5478,7 +5721,7 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
let owner = self.potion_owner(p.owner);
|
||||
self.splash(stack, at, mob, block, p.age, &owner);
|
||||
self.splash(stack, at, mob, block, p.age, &owner, p.velocity);
|
||||
if stack.item.as_ref() != "minecraft:experience_bottle" {
|
||||
let item = stack.item.to_string();
|
||||
self.native_potion_splash(&item, [at.x, at.y, at.z]);
|
||||
@@ -6711,6 +6954,17 @@ impl Game<'_> {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// The mods, before the projectile exists: a canceled throw is a
|
||||
// failed use, and the guest's client gets back the item it took.
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
let mut cancel = false;
|
||||
self.native_player_launch_projectile_v2(player, &name, stack, &mut cancel);
|
||||
if cancel {
|
||||
self.use_failed = true;
|
||||
self.guest_inventory_resend = true;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -6770,7 +7024,7 @@ impl Game<'_> {
|
||||
let parent = &self.mobs[index];
|
||||
if !parent.alive()
|
||||
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|
||||
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
|
||||
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
|
||||
// WanderingTrader.getBreedOffspring returns null: no offspring, the
|
||||
// click carries on to trading.
|
||||
|| parent.species() == Species::WanderingTrader
|
||||
@@ -8850,3 +9104,32 @@ mod pickable_projectile_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod breeding_orb_tests {
|
||||
use super::*;
|
||||
|
||||
/// `Animal.finalizeSpawnChildFromBreeding`: one fresh orb of the parent's
|
||||
/// 1-to-7 roll, never split (4 is not 3 + 1) or merged (#11388).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn breeding_drops_one_orb_of_the_whole_roll() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let kind = game.res.mob_types().get("minecraft:cow").unwrap().clone();
|
||||
game.mobs
|
||||
.push(Mob::new(7, kind, Vec3::new(8.5, 64.0, 8.5), 0.0));
|
||||
let mut values = std::collections::BTreeSet::new();
|
||||
for _ in 0..40 {
|
||||
let roll = 1 + game.mobs[0].random.clone().next_int_bound(7).unwrap() as u32;
|
||||
let orbs = game.orbs.len();
|
||||
game.breeding_orb(7, [8.5, 64.0, 8.5]);
|
||||
assert_eq!(game.orbs.len(), orbs + 1, "a fresh orb, not merged");
|
||||
assert_eq!(
|
||||
game.orbs.last().map(|o| (o.value, o.count)),
|
||||
Some((roll, 1))
|
||||
);
|
||||
values.insert(roll);
|
||||
}
|
||||
assert!(values.iter().any(|v| matches!(v, 4..=6)), "{values:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! OptiFine custom skies (`horizon_client::custom_sky`): each frame, the layers of the world's sky
|
||||
//! whose day, weather, biome and height hold at the camera, handed to the renderer, which draws
|
||||
//! them over the vanilla sky. A pack without `optifine/sky` hands it none.
|
||||
use super::*;
|
||||
use horizon_optifine::sky::{Conditions, Now};
|
||||
|
||||
/// The layers' eased brightness between frames, and when the last frame was.
|
||||
#[derive(Default)]
|
||||
pub(super) struct CustomSkyView {
|
||||
state: horizon_client::custom_sky::State,
|
||||
last: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Fills the renderer's custom sky layers for this frame; `camera` is the eye's position.
|
||||
/// Nothing for the title screen, the Nether (it has no sky) or a sky a shader pack draws.
|
||||
pub(super) fn custom_sky_frame(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
sky: &horizon_client::environment::Sky,
|
||||
(rain, thunder): (f32, f32),
|
||||
title: bool,
|
||||
) {
|
||||
let Some(mut draws) = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.map(|r| std::mem::take(r.custom_sky_draws()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
draws.clear();
|
||||
let world = match self.dim {
|
||||
Dim::Overworld => Some(0),
|
||||
Dim::End => Some(1),
|
||||
Dim::Nether => None,
|
||||
};
|
||||
let now = std::time::Instant::now();
|
||||
let seconds = self
|
||||
.custom_sky
|
||||
.last
|
||||
.replace(now)
|
||||
.map_or(0.0, |t| now.duration_since(t).as_secs_f32());
|
||||
if let Some(world) =
|
||||
world.filter(|&w| !title && self.settings.horizon.sky && self.pack.custom_sky.has(w))
|
||||
{
|
||||
let mut state = std::mem::take(&mut self.custom_sky.state);
|
||||
// Level.isThundering / isRaining.
|
||||
let weather = if thunder > 0.9 && rain > 0.2 {
|
||||
Now::Thunder
|
||||
} else if rain > 0.2 {
|
||||
Now::Rain
|
||||
} else {
|
||||
Now::Clear
|
||||
};
|
||||
let conditions = Conditions {
|
||||
day_time: self.view_day_time(),
|
||||
weather,
|
||||
biome: self.biome_at(camera),
|
||||
y: camera[1].floor() as i32,
|
||||
};
|
||||
self.pack.custom_sky.draws(
|
||||
world,
|
||||
&conditions,
|
||||
sky.sun_angle / 360.0,
|
||||
seconds,
|
||||
&mut state,
|
||||
&mut draws,
|
||||
);
|
||||
self.custom_sky.state = state;
|
||||
}
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
*renderer.custom_sky_draws() = draws;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const HINT: u32 = 0xAA_AAAA;
|
||||
|
||||
/// The open screen: its search box and the list's scroll.
|
||||
pub(super) struct DebugOptionsUi {
|
||||
search: EditBox,
|
||||
pub(super) search: EditBox,
|
||||
scroll: Scroll,
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,16 @@ pub(super) fn player_joined(id: u128, name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A player in the user cache by name (`UserCache::find_by_name`), on a dedicated server.
|
||||
pub(super) fn cached_user_by_name(name: &str) -> Option<NameAndId> {
|
||||
USER_CACHE.lock().unwrap().as_ref()?.find_by_name(name)
|
||||
}
|
||||
|
||||
/// A player in the user cache by id, on a dedicated server.
|
||||
pub(super) fn cached_user_by_id(id: u128) -> Option<NameAndId> {
|
||||
USER_CACHE.lock().unwrap().as_mut()?.get_by_id(id)
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; the
|
||||
/// default (on) without the file or the section, off (with an error logged) when the section is wrong.
|
||||
fn load_anticheat() -> horizon_anticheat::Config {
|
||||
@@ -1616,6 +1626,15 @@ impl Game<'_> {
|
||||
/// server (permission level 4). The dedicated-only commands are here;
|
||||
/// everything else goes to the game's dispatcher.
|
||||
fn console_command(&mut self, state: &mut ServerState, line: &str) {
|
||||
let (mut cancel, mut replies) = (false, Vec::new());
|
||||
let source = if self.rcon_source { "rcon" } else { "console" };
|
||||
self.native_console_line(source, line, &mut cancel, &mut replies);
|
||||
for reply in replies {
|
||||
self.console_reply(reply);
|
||||
}
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
let line = line.strip_prefix('/').unwrap_or(line);
|
||||
let mut words = line.split_whitespace();
|
||||
let Some(command) = words.next() else {
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(super) struct Recording {
|
||||
impl Recording {
|
||||
pub(super) fn start(seconds: u32, wire: Vec<(u32, (u64, u64))>) -> Self {
|
||||
let spans_were_on = spans::enabled();
|
||||
spans::enable(true);
|
||||
tick_profile::want_spans(tick_profile::SPANS_REPORT, true);
|
||||
job_stats::enable(true);
|
||||
job_stats::take();
|
||||
tick_profile::take();
|
||||
@@ -355,9 +355,8 @@ impl Game<'_> {
|
||||
pub(super) fn finish_report(&mut self, r: Recording, view: (u32, u32), max_players: usize) {
|
||||
let span_ns = span_cost_nanos();
|
||||
let mob_ns = tick_profile::sample_cost_nanos();
|
||||
if !r.spans_were_on {
|
||||
spans::enable(false);
|
||||
}
|
||||
// Off unless `HORIZON_SPIKES` or a mod (`native_spans`) still wants them.
|
||||
tick_profile::want_spans(tick_profile::SPANS_REPORT, false);
|
||||
job_stats::enable(false);
|
||||
let window_s = r.started.elapsed().as_secs_f64();
|
||||
let (ticks, spans, worst) = tick_summary(&r, window_s);
|
||||
|
||||
@@ -44,6 +44,8 @@ impl Game<'_> {
|
||||
/// Answers the requests waiting on the control endpoint.
|
||||
pub(super) fn poll_server_control(&mut self, state: &mut ServerState) {
|
||||
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
|
||||
// A `console stop` just before stops this server for the next request too.
|
||||
self.note_stopping(state.lists.lock().unwrap().stop);
|
||||
let reply = match request.line.trim().split_once(' ') {
|
||||
_ if request.line.trim() == "server-status" => self.server_status(state),
|
||||
// A proxy reading whether it may send players here (transfer.rs).
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! it sends players here). They run
|
||||
//! between two ticks, on the server thread, so the player is never caught half-way through a
|
||||
//! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s
|
||||
//! `[transfer]` is off.
|
||||
//! `[transfer]` is off, and those that bring a player in once the server has begun to stop.
|
||||
use crate::game::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::lan::arrivals;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
@@ -40,6 +40,16 @@ impl Game<'_> {
|
||||
reply
|
||||
}
|
||||
|
||||
/// Once a stop has begun (`/stop` from the console, a player or the control endpoint, or a stop
|
||||
/// signal), the transfers know it before the next request is answered: what would bring a player
|
||||
/// in is refused from then on, and the ways out stay open (`arrivals::Transfers::stopping`).
|
||||
pub(super) fn note_stopping(&mut self, stop: bool) {
|
||||
let stopping = stop || horizon_threads::stop_requests() > 0;
|
||||
if let Some(transfers) = self.lan.as_mut().and_then(|h| h.hub.transfers.as_mut()) {
|
||||
transfers.stopping |= stopping;
|
||||
}
|
||||
}
|
||||
|
||||
/// `transfer-status`: the counts a proxy reads before it sends players here
|
||||
/// (`arrivals::status_request`). A proxy asks it every few seconds, so it writes nothing to
|
||||
/// the log.
|
||||
|
||||
@@ -655,6 +655,8 @@ impl Game<'_> {
|
||||
v[2].as_f64().unwrap_or(0.0),
|
||||
)
|
||||
};
|
||||
// A new player: no combat of an earlier case's.
|
||||
self.combat = Default::default();
|
||||
let mut player = PlayerMotion::new(self.motion, v3(&op["player"]))?;
|
||||
player.velocity = v3(&op["motion"]);
|
||||
player.fall_distance = op["fall"].as_f64().unwrap_or(0.0);
|
||||
@@ -670,6 +672,7 @@ impl Game<'_> {
|
||||
let used_before = stack
|
||||
.as_ref()
|
||||
.map_or(0, |s| self.stats.get("used", &s.item));
|
||||
let taken_before = self.stats.get("custom", "minecraft:damage_taken");
|
||||
let base = op["base"].as_f64().unwrap_or(0.0) as f32;
|
||||
let source = self.attack_source(feet);
|
||||
let (bonus, hurt) = match targets.first() {
|
||||
@@ -707,12 +710,39 @@ impl Game<'_> {
|
||||
};
|
||||
self.process_level_events()?;
|
||||
let mut out = Vec::new();
|
||||
// What the player's tick makes of its own hurt (the victim's Thorns):
|
||||
// the combat record with its statistic, the hurt sound where it
|
||||
// stands and the damage game event of the health it lost.
|
||||
let events = self
|
||||
.player
|
||||
.as_mut()
|
||||
.map(|p| std::mem::take(&mut p.events))
|
||||
.unwrap_or_default();
|
||||
self.record_combat(&events);
|
||||
for event in &events {
|
||||
if let horizon_client::motion::MotionEvent::HurtSound(sound) = event {
|
||||
out.push(format!("S {} {sound}", floor_pos(feet)));
|
||||
}
|
||||
}
|
||||
if self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.health() < 20.0)
|
||||
{
|
||||
self.post_entity_event("minecraft:entity_damage", horizon_client::mobs::Who::Player);
|
||||
}
|
||||
if let Some(s) = &stack {
|
||||
let used = self.stats.get("used", &s.item) - used_before;
|
||||
if used != 0 {
|
||||
out.push(format!("stat {} minecraft:used {used}", s.item));
|
||||
}
|
||||
}
|
||||
let taken = self.stats.get("custom", "minecraft:damage_taken") - taken_before;
|
||||
if taken != 0 {
|
||||
out.push(format!(
|
||||
"stat minecraft:damage_taken minecraft:custom {taken}"
|
||||
));
|
||||
}
|
||||
let source = match source.kind {
|
||||
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
|
||||
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
|
||||
@@ -743,6 +773,25 @@ impl Game<'_> {
|
||||
num(v.z)
|
||||
));
|
||||
}
|
||||
// What the first target wears, worn by its Thorns.
|
||||
if let Some(&Target::Mob(id)) = targets.first()
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
{
|
||||
use horizon_client::mobs::finalize as f;
|
||||
for (slot, name) in [
|
||||
(f::FEET, "feet"),
|
||||
(f::LEGS, "legs"),
|
||||
(f::CHEST, "chest"),
|
||||
(f::HEAD, "head"),
|
||||
] {
|
||||
if let Some(s) = f::item_stack(m, slot) {
|
||||
out.push(format!("W {name} {}", canon_stack(Some(&s))));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
out.push(format!("H {}", num(f64::from(p.health.health()))));
|
||||
}
|
||||
if let Some(p) = self.player.take() {
|
||||
let impact = p.impulse.impact_position.map_or("-".to_owned(), |i| {
|
||||
format!("{},{},{}", num(i.x), num(i.y), num(i.z))
|
||||
@@ -1044,12 +1093,20 @@ impl Game<'_> {
|
||||
if flag("brown") && s == Species::Mooshroom {
|
||||
mob.extra.variant = 1;
|
||||
}
|
||||
for (slot, snbt) in e["equip"].as_object().into_iter().flatten() {
|
||||
for (name, snbt) in e["equip"].as_object().into_iter().flatten() {
|
||||
if let (Some(slot), Some(stack)) = (
|
||||
Slot::from_name(slot),
|
||||
Slot::from_name(name),
|
||||
parse_stack(snbt.as_str().unwrap_or("-"))?,
|
||||
) {
|
||||
mob_set_slot(&mut mob, slot, stack);
|
||||
// `setItemSlot` keeps the whole stack: a piece's enchantments and damage.
|
||||
let kept = ["mainhand", "offhand", "feet", "legs", "chest", "head"]
|
||||
.iter()
|
||||
.position(|s| s == name)
|
||||
.filter(|_| stack.extra.is_some() || stack.damage > 0);
|
||||
match kept {
|
||||
Some(at) => horizon_client::mobs::finalize::set_item_stack(&mut mob, at, stack),
|
||||
None => mob_set_slot(&mut mob, slot, stack),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
|
||||
@@ -17,6 +17,9 @@ use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
|
||||
use serde_json::Value;
|
||||
|
||||
/// `EquipmentSlot.getName` of each `mobs::finalize` slot (hands, feet to head).
|
||||
const MOB_SLOTS: [&str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
enum MendSlot {
|
||||
@@ -178,8 +181,12 @@ impl Game<'_> {
|
||||
let _ = p.set_sprinting(false);
|
||||
}
|
||||
}
|
||||
/// After a successful melee hit: fire aspect and wind burst.
|
||||
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource` after a successful
|
||||
/// melee hit (`Player.itemAttackInteraction`, the sweep's
|
||||
/// `doPostAttackEffects`): the victim's equipment first (Thorns), then the
|
||||
/// attacker's weapon on the victim (fire aspect and wind burst).
|
||||
pub(super) fn post_attack(&mut self, mob: usize) {
|
||||
self.victim_post_attack(mob, "minecraft:player", None);
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
@@ -519,6 +526,152 @@ impl Game<'_> {
|
||||
}
|
||||
hits
|
||||
}
|
||||
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource`'s `VICTIM` loop
|
||||
/// after the player's hit hurt the mob at `victim` (`direct` is the hit's
|
||||
/// direct entity: the player, or its arrow or trident):
|
||||
/// `runIterationOnEquipment` over what the mob holds and wears, in
|
||||
/// `EquipmentSlot` order, runs the `minecraft:post_attack` effects with
|
||||
/// `enchanted: victim` of each enchantment on the player (Thorns).
|
||||
pub(super) fn victim_post_attack(
|
||||
&mut self,
|
||||
victim: usize,
|
||||
direct: &str,
|
||||
damage_type: Option<&str>,
|
||||
) {
|
||||
use horizon_client::mobs::finalize as f;
|
||||
let Some(m) = self.mobs.get(victim) else {
|
||||
return;
|
||||
};
|
||||
let (id, kind) = (m.id, m.kind.id.clone());
|
||||
// EquipmentSlot.VALUES: the hands, then the feet up to the head (the
|
||||
// body and the saddle keep no enchantments here).
|
||||
for slot in [f::MAINHAND, f::OFFHAND, f::FEET, f::LEGS, f::CHEST, f::HEAD] {
|
||||
let Some(stack) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| f::item_stack(m, slot))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = MOB_SLOTS[slot];
|
||||
let hand = slot == f::MAINHAND || slot == f::OFFHAND;
|
||||
let mut effects = Vec::new();
|
||||
for (enchantment, level) in stack.enchantments() {
|
||||
let Some(def) = world_stations!(self).enchantments.get(enchantment) else {
|
||||
continue;
|
||||
};
|
||||
// Enchantment.matchingSlot
|
||||
if !def.slots.iter().any(|s| {
|
||||
s == name || s == "any" || (s == "armor" && !hand) || (s == "hand" && hand)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:post_attack"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["enchanted"] == "victim" {
|
||||
effects.push((effect.clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (effect, level) in effects {
|
||||
// Enchantment.damageContext: random_chance draws from the level's random.
|
||||
let ctx = EffectContext {
|
||||
this: Some(&kind),
|
||||
attacker: Some("minecraft:player"),
|
||||
direct_attacker: Some(direct),
|
||||
is_direct: direct == "minecraft:player",
|
||||
damage_type,
|
||||
items: Some(world_items!(self)),
|
||||
..Default::default()
|
||||
};
|
||||
let ok = effect.get("requirements").is_none_or(|r| {
|
||||
world_stations!(self).enchantments.condition(
|
||||
r,
|
||||
level,
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
)
|
||||
});
|
||||
// The player is the attacker; with no attacker nothing applies.
|
||||
if ok && effect["affected"] == "attacker" && self.player.is_some() {
|
||||
self.victim_effect_on_player(&effect["effect"], level, id, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `DamageEntity.apply`, `ChangeItemDamage.apply` (on the piece) and
|
||||
/// `AllOf` for one `post_attack` effect of the piece the mob `wearer` has
|
||||
/// in `slot`, on the player that hit it.
|
||||
fn victim_effect_on_player(&mut self, effect: &Value, level: u32, wearer: u32, slot: usize) {
|
||||
match effect["type"]
|
||||
.as_str()
|
||||
.map(|t| t.trim_start_matches("minecraft:"))
|
||||
{
|
||||
Some("all_of") => {
|
||||
for e in effect["effects"].as_array().into_iter().flatten() {
|
||||
self.victim_effect_on_player(e, level, wearer, slot);
|
||||
}
|
||||
}
|
||||
// Mth.randomBetween from the hurt entity's own random (the
|
||||
// player's, which no level draw shares), then its hurtServer by a
|
||||
// source the wearer caused: from where the wearer stands.
|
||||
Some("damage_entity") => {
|
||||
let min = level_value(&effect["min_damage"], level);
|
||||
let max = level_value(&effect["max_damage"], level);
|
||||
let damage = super::dispenser::entity_random().next_float() * (max - min) + min;
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == wearer) else {
|
||||
return;
|
||||
};
|
||||
let from = [m.position.x, m.position.y, m.position.z];
|
||||
let kind = effect["damage_type"].as_str().unwrap_or("minecraft:thorns");
|
||||
self.mob_hits_player_caused(damage, from, kind, Some(wearer));
|
||||
}
|
||||
Some("change_item_damage") => {
|
||||
let amount = level_value(&effect["amount"], level) as u32;
|
||||
self.wear_mob_piece(wearer, slot, amount);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `ItemStack.hurtAndBreak` with no player for what the mob `id` has in
|
||||
/// `slot`: Unbreaking takes its share from the level's random, and a
|
||||
/// piece worn out leaves the slot with its break event
|
||||
/// (`LivingEntity.onEquippedItemBroken`).
|
||||
fn wear_mob_piece(&mut self, id: u32, slot: usize, amount: u32) {
|
||||
use horizon_client::mobs::finalize as f;
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let Some(stack) = f::item_stack(m, slot).filter(|s| {
|
||||
amount > 0 && horizon_client::stations::is_damageable(world_items!(self), s)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let loss = self.durability_loss(&stack, amount);
|
||||
if loss == 0 {
|
||||
return;
|
||||
}
|
||||
let damage = stack.damage + loss;
|
||||
let max = world_items!(self)
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if damage < max {
|
||||
f::set_slot_damage(m, slot, damage);
|
||||
return;
|
||||
}
|
||||
horizon_client::mobs::drops::clear_slot(m, MOB_SLOTS[slot]);
|
||||
// LivingEntity.entityEventForEquipmentBreak
|
||||
let event = [47, 48, 52, 51, 50, 49][slot];
|
||||
self.lan_entity_event(id as i32, event, at);
|
||||
}
|
||||
/// `ExperienceOrb.repairPlayerItems`: returns the experience left over.
|
||||
pub(super) fn mending(&mut self, amount: u32) -> u32 {
|
||||
let mut amount = amount;
|
||||
@@ -840,16 +993,26 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let on_ground = p.ground.on_ground;
|
||||
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
|
||||
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
|
||||
// LivingEntity.collectEquipmentChanges: newly worn armor runs its
|
||||
// location_changed effects at once, even when standing still.
|
||||
let changed = armor_changes(&mut self.location_armor, &self.armor);
|
||||
let moved = cell(from) != cell(at) || (on_ground && !was_on_ground);
|
||||
if !moved && !changed.iter().any(|c| *c) {
|
||||
return;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let disks = location_disks(
|
||||
&self.stations().enchantments,
|
||||
self.armor.iter().zip(["head", "chest", "legs", "feet"]),
|
||||
on_ground,
|
||||
riding,
|
||||
);
|
||||
let slots = ["head", "chest", "legs", "feet"];
|
||||
let mut disks = Vec::new();
|
||||
for i in 0..4 {
|
||||
if moved || changed[i] {
|
||||
disks.extend(location_disks(
|
||||
&self.stations().enchantments,
|
||||
std::iter::once((&self.armor[i], slots[i])),
|
||||
on_ground,
|
||||
riding,
|
||||
));
|
||||
}
|
||||
}
|
||||
for (effect, level) in disks {
|
||||
self.replace_disk(&effect, level, at, Who::Player);
|
||||
}
|
||||
@@ -1182,9 +1345,43 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.collectEquipmentChanges`: which armor slots differ from the
|
||||
/// `snapshot` taken at the last look, which is then brought up to date.
|
||||
fn armor_changes(
|
||||
snapshot: &mut [Option<ItemStack>; 4],
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> [bool; 4] {
|
||||
let changed: [bool; 4] = std::array::from_fn(|i| armor[i] != snapshot[i]);
|
||||
// Only a changed slot is copied: the check runs every tick for every player.
|
||||
for ((kept, worn), changed) in snapshot.iter_mut().zip(armor).zip(changed) {
|
||||
if changed {
|
||||
kept.clone_from(worn);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::block_break_speed_factor;
|
||||
use super::{armor_changes, block_break_speed_factor};
|
||||
use horizon_client::items::ItemStack;
|
||||
|
||||
/// The snapshot reports a newly worn piece once, then settles.
|
||||
#[test]
|
||||
fn armor_changes_settle_after_one_look() {
|
||||
let worn = [
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(ItemStack::new("minecraft:iron_boots", 1)),
|
||||
];
|
||||
let mut snapshot: [Option<ItemStack>; 4] = Default::default();
|
||||
assert_eq!(
|
||||
armor_changes(&mut snapshot, &worn),
|
||||
[false, false, false, true]
|
||||
);
|
||||
assert_eq!(armor_changes(&mut snapshot, &worn), [false; 4]);
|
||||
}
|
||||
|
||||
/// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed.
|
||||
#[test]
|
||||
|
||||
@@ -408,6 +408,8 @@ impl Game<'_> {
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_player_fish(uuid, &name, state, &mut cancel);
|
||||
let hand = self.hand;
|
||||
self.native_player_fish_v2(uuid, &name, state, hand, &mut cancel);
|
||||
if cancel {
|
||||
return true;
|
||||
}
|
||||
@@ -766,6 +768,17 @@ impl Game<'_> {
|
||||
{
|
||||
let mut cancel = false;
|
||||
self.native_player_fish(uuid, &name, 1, &mut cancel);
|
||||
// A bite has no hand of its own: the one holding the rod, the main
|
||||
// hand first, as `FishingHook.shouldStopFishing` looks.
|
||||
let hand = if self
|
||||
.stack_in(self.selected)
|
||||
.is_some_and(|s| &*s.item == "minecraft:fishing_rod")
|
||||
{
|
||||
horizon_client::first_person::Hand::Main
|
||||
} else {
|
||||
horizon_client::first_person::Hand::Off
|
||||
};
|
||||
self.native_player_fish_v2(uuid, &name, 1, hand, &mut cancel);
|
||||
if cancel {
|
||||
h.time_until_lured = 0;
|
||||
h.time_until_hooked = 0;
|
||||
@@ -896,6 +909,10 @@ impl Game<'_> {
|
||||
];
|
||||
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
|
||||
let in_water = liquid > 0.0;
|
||||
// The block a flying bobber meets or rests on this tick (and the face), for
|
||||
// `native_fishing_hook_landed` once the tick is done.
|
||||
let rested = h.on_ground;
|
||||
let mut landed: Option<([i32; 3], [i32; 3])> = None;
|
||||
match h.state {
|
||||
State::Flying => {
|
||||
if in_water {
|
||||
@@ -908,7 +925,8 @@ impl Game<'_> {
|
||||
// checkCollision: find the first block and entity on the movement
|
||||
// segment so a fast hook cannot tunnel or catch through a wall.
|
||||
let to = h.position.add_vector(h.velocity);
|
||||
let block_hit = self.hook_block_hit(h.position, to);
|
||||
let block_hit_at = self.hook_block_hit_at(h.position, to);
|
||||
let block_hit = block_hit_at.map(|hit| hit.location);
|
||||
let entity_end = block_hit.unwrap_or(to);
|
||||
let owner = self.lan_acting;
|
||||
let own_vehicle = self.own_vehicle();
|
||||
@@ -979,6 +997,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
} else if let Some(hit) = block_hit {
|
||||
landed = block_hit_at
|
||||
.map(|at| ([at.block.x, at.block.y, at.block.z], at.direction.normal()));
|
||||
// Projectile.onHit on a block: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
@@ -1101,7 +1121,24 @@ impl Game<'_> {
|
||||
h.velocity.y * 0.92,
|
||||
h.velocity.z * 0.92,
|
||||
);
|
||||
// A bobber lying on a block (on the ground as the tick began or ends) rests on
|
||||
// the block under its centre, on that block's top.
|
||||
if landed.is_none() && h.state == State::Flying && (rested || h.on_ground) {
|
||||
let below = [
|
||||
h.position.x.floor() as i32,
|
||||
(h.position.y - 1.0e-4).floor() as i32,
|
||||
h.position.z.floor() as i32,
|
||||
];
|
||||
landed = Some((below, [0, 1, 0]));
|
||||
}
|
||||
let id = h.id;
|
||||
self.hook = Some(h);
|
||||
// The mods hear a server player's bobber on a block with the bobber in place, so
|
||||
// `native_remove_entity` can take it.
|
||||
if let (Some((block, face)), Some(player)) = (landed, self.lan_acting_uuid) {
|
||||
let dimension = self.dim.id();
|
||||
self.native_fishing_hook_landed(player, id, dimension, block, face);
|
||||
}
|
||||
}
|
||||
/// The players a hook can catch: everyone but spectators and the dead
|
||||
/// (`Projectile.canHitEntity`), the host and the guests in the level.
|
||||
@@ -1601,6 +1638,10 @@ impl Game<'_> {
|
||||
|
||||
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
|
||||
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
|
||||
self.hook_block_hit_at(from, to).map(|hit| hit.location)
|
||||
}
|
||||
/// [`Self::hook_block_hit`] with the block and the face it meets.
|
||||
fn hook_block_hit_at(&self, from: Vec3, to: Vec3) -> Option<horizon_core::geometry::BlockHit> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -1618,7 +1659,6 @@ impl Game<'_> {
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|hit| hit.location)
|
||||
}
|
||||
|
||||
/// `FishingHook.tick`: one tick of movement without the fishing (the
|
||||
@@ -1726,6 +1766,43 @@ impl Game<'_> {
|
||||
(position, velocity, state, on_ground)
|
||||
}
|
||||
|
||||
/// Takes the server's fishing bobber `id` out of the level in place, for
|
||||
/// `native_remove_entity`: the bobber of the player in place, or another of this
|
||||
/// level's players' (out of reach during a guest's turn, when the hub is taken out).
|
||||
/// Its tracking removes it from the clients; the rod is left as when the bobber goes
|
||||
/// by itself. Whether it was there.
|
||||
pub(super) fn remove_server_hook(&mut self, id: u32) -> bool {
|
||||
if self.remote.is_some() || id == 0 {
|
||||
return false;
|
||||
}
|
||||
if self.hook.as_ref().is_some_and(|h| h.id == id) {
|
||||
self.hook = None;
|
||||
return true;
|
||||
}
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let owner = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.map(|g| g.conn)
|
||||
.find(|conn| {
|
||||
host.play
|
||||
.get(conn)
|
||||
.and_then(|p| p.hook.as_ref())
|
||||
.is_some_and(|h| h.id == id)
|
||||
});
|
||||
match owner.and_then(|conn| host.play.get_mut(&conn)) {
|
||||
Some(play) => {
|
||||
play.hook = None;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_hook(&mut self, id: u32) {
|
||||
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
|
||||
self.hook = None;
|
||||
|
||||
@@ -283,6 +283,13 @@ impl Game<'_> {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
// A map tracking more than 256 counted markers stays in the hand
|
||||
// (InteractionResult.FAIL): nothing else is tried, no swing (#11383).
|
||||
if let Some(id) = super::maps::map_id(&held)
|
||||
&& self.map(id).is_some_and(|m| m.tracked_over_limit(256))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
self.item_frames[i].item = Some(ItemStack { count: 1, ..held });
|
||||
action = "put";
|
||||
self.audio
|
||||
@@ -704,3 +711,76 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod map_limit_tests {
|
||||
use super::super::maps::{Decoration, Key, MapData};
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
|
||||
fn frame(id: u32) -> ItemFrame {
|
||||
ItemFrame {
|
||||
id,
|
||||
pos: [0, 64, id as i32],
|
||||
facing: 3,
|
||||
glow: false,
|
||||
item: None,
|
||||
rotation: 0,
|
||||
drop_chance: 1.0,
|
||||
fixed: false,
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
custom_name: None,
|
||||
}
|
||||
}
|
||||
/// A map whose `count` frame markers all count toward the limit.
|
||||
fn map_tracking(count: i32) -> MapData {
|
||||
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
|
||||
map.decorations = (0..count)
|
||||
.map(|i| Decoration {
|
||||
sprite: "map/decorations/frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
rot: 0,
|
||||
name: None,
|
||||
key: Key::Frame(1000 + i),
|
||||
})
|
||||
.collect();
|
||||
map
|
||||
}
|
||||
fn filled_map(id: i32) -> ItemStack {
|
||||
let mut stack = ItemStack::new("minecraft:filled_map", 1);
|
||||
stack.data_mut().map_id = Some(id);
|
||||
stack
|
||||
}
|
||||
fn at(id: u32) -> Option<InteractTarget> {
|
||||
Some(InteractTarget {
|
||||
id: id as i32,
|
||||
location: [0.0; 3],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_map_tracking_more_than_256_markers_stays_in_the_hand() {
|
||||
let mut game = server_game();
|
||||
game.item_frames = vec![frame(1), frame(2)];
|
||||
game.maps.data.insert(7, map_tracking(257));
|
||||
game.maps.data.insert(8, map_tracking(256));
|
||||
|
||||
// ItemFrame.interact: FAIL, the frame stays empty and the map is kept.
|
||||
*game.held_mut() = Some(filled_map(7));
|
||||
assert!(
|
||||
game.interact_frame(at(1)),
|
||||
"the refusal ends the interaction"
|
||||
);
|
||||
assert!(game.item_frames[0].item.is_none());
|
||||
assert_eq!(game.held().and_then(|s| s.data()?.map_id), Some(7));
|
||||
|
||||
// At the limit (not over it) the map goes in.
|
||||
*game.held_mut() = Some(filled_map(8));
|
||||
assert!(game.interact_frame(at(2)));
|
||||
let framed = game.item_frames[1].item.as_ref();
|
||||
assert_eq!(framed.and_then(|s| s.data()?.map_id), Some(8));
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,10 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
/// `EntityGetter.getEntityCollisions` for a dropped item: the entities whose
|
||||
/// `canBeCollidedWith(item)` is true. A boat and a minecart always, a living shulker,
|
||||
/// and a happy ghast only while it is held still (`isOnStillTimeout`): a player on its
|
||||
/// back keeps it still, so an item dropped there stays on top.
|
||||
/// `canBeCollidedWith(item)` is true. A boat always, a living shulker, and a happy
|
||||
/// ghast only while it is held still (`isOnStillTimeout`): a player on its back keeps
|
||||
/// it still, so an item dropped there stays on top. A minecart keeps `Entity`'s false
|
||||
/// (`AbstractMinecart` overrides only `canCollideWith`), so an item falls through it.
|
||||
pub(super) fn item_entity_collisions(&self) -> Vec<Aabb> {
|
||||
self.mobs
|
||||
.iter()
|
||||
@@ -52,7 +53,6 @@ impl Game<'_> {
|
||||
})
|
||||
.map(|m| m.bounds())
|
||||
.chain(self.boats.iter().map(super::boats::Boat::bounds))
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.collect()
|
||||
}
|
||||
/// `HappyGhast.mobInteract`. Returns whether the click was used.
|
||||
@@ -179,3 +179,88 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod item_collision_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An item dropped onto a minecart falls through it to the floor, and one
|
||||
/// dropped onto a boat rests on the boat (`canBeCollidedWith`).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dropped_item_rests_on_a_boat_but_falls_through_a_minecart() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::env::temp_dir().join(format!("horizon-item-cart-unit-{}", std::process::id())),
|
||||
);
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap()
|
||||
.0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
chunk.set(x, 199, z, stone);
|
||||
}
|
||||
}
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("summon minecart 4.5 200 4.5"));
|
||||
assert!(game.run_command("summon oak_boat 10.5 200 10.5"));
|
||||
for (x, z) in [(4.5, 4.5), (10.5, 10.5)] {
|
||||
assert!(game.run_command(&format!(
|
||||
"summon item {x} 201.5 {z} {{Item:{{id:\"minecraft:stick\",count:1}},Motion:[0.0d,0.0d,0.0d]}}"
|
||||
)));
|
||||
}
|
||||
for _ in 0..60 {
|
||||
game.tick_world(true).unwrap();
|
||||
}
|
||||
let rest = |x: f64| {
|
||||
game.dropped
|
||||
.iter()
|
||||
.find(|d| (d.position.x - x).abs() < 0.5)
|
||||
.map(|d| d.position.y)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(rest(4.5), 200.0, "through the minecart to the stone");
|
||||
let boat_top = game.boats[0].bounds().max_position().y;
|
||||
assert_eq!(rest(10.5), boat_top, "on the boat");
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,15 @@ impl Game<'_> {
|
||||
pub(super) fn swing_arm(&mut self) {
|
||||
self.swing_hand(self.hand, SwingAnimation::DEFAULT);
|
||||
}
|
||||
/// `swing_arm` for a use whose result is `SwingSource.PREDICTED`: the
|
||||
/// player's client swings by itself, so a server keeps it to the others.
|
||||
pub(super) fn swing_arm_predicted(&mut self) {
|
||||
let before = self.swings_started;
|
||||
self.swing_arm();
|
||||
if self.swings_started != before {
|
||||
self.predicted_swings += 1;
|
||||
}
|
||||
}
|
||||
/// `Player.resetAttackStrengthTicker`.
|
||||
pub(super) fn reset_attack_strength(&mut self) {
|
||||
self.attack_ticks = 0;
|
||||
|
||||
@@ -100,7 +100,17 @@ impl Game<'_> {
|
||||
/// Text the input method committed (`WindowEvent::Ime(Commit)`).
|
||||
pub(super) fn ime_commit(&mut self, text: &str) {
|
||||
preedit::set_preedit(None);
|
||||
// Dialogs have their own fields; the other topmost prompts take no text.
|
||||
if self.prompt_open() && !self.dialog_open() {
|
||||
return;
|
||||
}
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
if let Some(result) = self.type_text_field_input(&text) {
|
||||
if let Err(error) = result {
|
||||
log::warn!("Horizon: text input: {error}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ScreenEvent.CharacterTyped.Pre: the input method's committed text
|
||||
// reaches a text box by characters, as a key's own text does, so the
|
||||
// open screen hears of each of them first and a cancelled one never
|
||||
|
||||
@@ -561,6 +561,7 @@ impl Game<'_> {
|
||||
self.server_packs_world_pack(&folder);
|
||||
self.connect_local(pipe, &player)?;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.save_folder = Some(folder.clone());
|
||||
// `new LevelLoadTracker(newWorld ? 500L : 0L)`.
|
||||
r.level_load = horizon_client::level_loading::LevelLoadTracker::new(if new_world {
|
||||
horizon_client::level_loading::LEVEL_LOAD_CLOSE_DELAY_MS
|
||||
@@ -971,7 +972,8 @@ impl Game<'_> {
|
||||
g.chat_capture = Some(Vec::new());
|
||||
// A command a native mod registered goes to the mods
|
||||
// first, as the owner's own chat does (the console is
|
||||
// level 4, so every registered usage is allowed).
|
||||
// level 4, so every registered usage is allowed; one with
|
||||
// a permission node asks the mods for the owner).
|
||||
if !g.perform_native_command(conn, 4, command.trim()) {
|
||||
g.run_command(&command);
|
||||
}
|
||||
|
||||
Loaded 100 of 543 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user