mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 02:21:38 -10:00
Compare commits
317
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85bfd116a7 | ||
|
|
3dc5b432fe | ||
|
|
d454cd53fb | ||
|
|
deff92036a | ||
|
|
230d0d5911 | ||
|
|
fcd9f1b401 | ||
|
|
68f554e7dc | ||
|
|
ee637b2567 | ||
|
|
cc6bfe2bc3 | ||
|
|
57b2aa470b | ||
|
|
c8ad6a8840 | ||
|
|
57064f15d0 | ||
|
|
21f460c630 | ||
|
|
d05e95409d | ||
|
|
f4852b0660 | ||
|
|
9304a5387e | ||
|
|
6b25a75238 | ||
|
|
36e376591a | ||
|
|
926875ace4 | ||
|
|
1b4ef1de92 | ||
|
|
91e1f8a8cd | ||
|
|
575c9b20df | ||
|
|
e65680f925 | ||
|
|
e24e645156 | ||
|
|
d5ff486d31 | ||
|
|
b477ff2689 | ||
|
|
8a65204b3a | ||
|
|
8115a3a4b1 | ||
|
|
65bc3d80b5 | ||
|
|
0a6d8aa90a | ||
|
|
497e13808f | ||
|
|
90ca4e0491 | ||
|
|
23d5461fe0 | ||
|
|
65688ae0f0 | ||
|
|
8b718c5d5e | ||
|
|
c5d7bae5fe | ||
|
|
b296f7e2ea | ||
|
|
0f4db0c569 | ||
|
|
f541e240fb | ||
|
|
0ba58d9229 | ||
|
|
450d8c35dc | ||
|
|
e280d96cf2 | ||
|
|
241684332d | ||
|
|
59ec871592 | ||
|
|
cbf9b8d5ab | ||
|
|
00643bca10 | ||
|
|
7ac8e3a30a | ||
|
|
1fdcf6b385 | ||
|
|
6d669b0a59 | ||
|
|
93b9775439 | ||
|
|
c723e0894c | ||
|
|
6f1e0a2463 | ||
|
|
08191e2f40 | ||
|
|
439e0ee259 | ||
|
|
a18e7d5d1f | ||
|
|
ec27a1e0b0 | ||
|
|
1d4b04ceb3 | ||
|
|
07c8bdbc0b | ||
|
|
adfda4bd26 | ||
|
|
502a3026aa | ||
|
|
9d9fe2298e | ||
|
|
3839d80076 | ||
|
|
26752debe1 | ||
|
|
65d68266e2 | ||
|
|
c418e970be | ||
|
|
8930bb3faf | ||
|
|
cd9867da5c | ||
|
|
1ee73d821d | ||
|
|
7680fee325 | ||
|
|
860dbf51e1 | ||
|
|
3ef3272ee2 | ||
|
|
9cc33bdda8 | ||
|
|
2d621405c6 | ||
|
|
a0b0a5ba50 | ||
|
|
5fbb18a139 | ||
|
|
1fdd195790 | ||
|
|
95fe89ca0d | ||
|
|
cfb61d623a | ||
|
|
711f099ac8 | ||
|
|
4f43f49cf7 | ||
|
|
ad7b45d3b6 | ||
|
|
35fbfb00dd | ||
|
|
9ca4d10841 | ||
|
|
9e2c0dfde2 | ||
|
|
7b52aa1f85 | ||
|
|
05a7663aab | ||
|
|
2f82051fb4 | ||
|
|
f1b2d7b86f | ||
|
|
223a97787d | ||
|
|
e5e27a7aac | ||
|
|
a65989354d | ||
|
|
83c79c4d4f | ||
|
|
5b372e2f35 | ||
|
|
e3eb634eba | ||
|
|
c00e5801aa | ||
|
|
b1f7c78d54 | ||
|
|
f9676a54ce | ||
|
|
b9f5a45c62 | ||
|
|
b357010174 | ||
|
|
9b611a200b | ||
|
|
2fc88ea632 | ||
|
|
10a4ec4e2d | ||
|
|
5ddb4828b5 | ||
|
|
9bb2c8818d | ||
|
|
40f8bd80da | ||
|
|
e44f25cae1 | ||
|
|
65d2b5e8fe | ||
|
|
c620da0f19 | ||
|
|
e1093d6800 | ||
|
|
148b33f41d | ||
|
|
b80768cd4a | ||
|
|
c8a8bf357b | ||
|
|
d914ceeb53 | ||
|
|
b213e5346b | ||
|
|
2509598c13 | ||
|
|
f0d20beff8 | ||
|
|
934136e813 | ||
|
|
b2bf03c749 | ||
|
|
22b8af021c | ||
|
|
111081b1bc | ||
|
|
1d383e73b3 | ||
|
|
bb674debd6 | ||
|
|
32b24a9e8e | ||
|
|
ee837e88be | ||
|
|
470fb25a89 | ||
|
|
40fee24b86 | ||
|
|
29c318b541 | ||
|
|
a8f57b1997 | ||
|
|
94e2666542 | ||
|
|
2541cba005 | ||
|
|
9c54d6b294 | ||
|
|
6eac540995 | ||
|
|
93892c4049 | ||
|
|
067b5877d2 | ||
|
|
52890d2f8f | ||
|
|
cbc12f96bf | ||
|
|
b1c5484243 | ||
|
|
59dc4a6f14 | ||
|
|
f1ed2f36e6 | ||
|
|
1028b8f19c | ||
|
|
4cc2631bc0 | ||
|
|
4e8bdad871 | ||
|
|
f77d6da3bd | ||
|
|
46a3761a41 | ||
|
|
8b4aa65bbd | ||
|
|
8676990b18 | ||
|
|
6f899414a7 | ||
|
|
e3bcd0fdba | ||
|
|
a52f904ff5 | ||
|
|
f4d91f2fbb | ||
|
|
cf8cab121c | ||
|
|
7bcd15e22d | ||
|
|
c17233b793 | ||
|
|
c644282b7b | ||
|
|
704dff73bd | ||
|
|
2fb0d5b493 | ||
|
|
b8511b4888 | ||
|
|
88fd421269 | ||
|
|
ce1500dbbb | ||
|
|
3811132bd2 | ||
|
|
4c6d49a02f | ||
|
|
4f17e42f93 | ||
|
|
ddd3d4d0e3 | ||
|
|
12e5483917 | ||
|
|
d7ff063035 | ||
|
|
c597f06d65 | ||
|
|
c904d2fa1c | ||
|
|
094a083540 | ||
|
|
5c188c619b | ||
|
|
cce03e9367 | ||
|
|
c08119ea7a | ||
|
|
088390b4fe | ||
|
|
4f5c8307e8 | ||
|
|
b3516f66e2 | ||
|
|
db83571428 | ||
|
|
c58580ead2 | ||
|
|
1aae74b8b1 | ||
|
|
270431ec55 | ||
|
|
6c9ab657fd | ||
|
|
4851a7ab4d | ||
|
|
7570dcff24 | ||
|
|
9e2440253f | ||
|
|
ee4108fdaf | ||
|
|
5d41303466 | ||
|
|
0e515dbfd3 | ||
|
|
5747b52d0c | ||
|
|
bc105393f0 | ||
|
|
e1c4d5dc88 | ||
|
|
49b4dbec3d | ||
|
|
2e1da6db52 | ||
|
|
0820541751 | ||
|
|
cd53a3c0dd | ||
|
|
9fd99d5a4d | ||
|
|
4ecf521195 | ||
|
|
f554aa5a6c | ||
|
|
11eecf5a6a | ||
|
|
f472da81d1 | ||
|
|
d65cd8af19 | ||
|
|
15dce73eb4 | ||
|
|
9f4f39addc | ||
|
|
b57d440412 | ||
|
|
253b1f8780 | ||
|
|
d671006122 | ||
|
|
bddb7c34be | ||
|
|
23bceb746d | ||
|
|
0c66d4678d | ||
|
|
9ad0a3abf1 | ||
|
|
90e269dc36 | ||
|
|
0bfa991471 | ||
|
|
f15483df83 | ||
|
|
d72c67d81a | ||
|
|
45f155e801 | ||
|
|
bcb2087d2f | ||
|
|
9bab876eb4 | ||
|
|
2f41aedb39 | ||
|
|
230ae976e1 | ||
|
|
e5f3507585 | ||
|
|
c094313726 | ||
|
|
703f9ec127 | ||
|
|
f935ff68a5 | ||
|
|
d9d8f5e2e8 | ||
|
|
5168e4db30 | ||
|
|
5e2e5b4291 | ||
|
|
05d810136c | ||
|
|
8036e0de7f | ||
|
|
874c4d1cdb | ||
|
|
88a19fab33 | ||
|
|
7fb5b6f214 | ||
|
|
0d0e9d4213 | ||
|
|
8b23993cfb | ||
|
|
fd16400b91 | ||
|
|
a62fa7375d | ||
|
|
23200373f6 | ||
|
|
b6c585706f | ||
|
|
daa6d3b1ca | ||
|
|
468dd3c24b | ||
|
|
9b6a9a6b55 | ||
|
|
0d9dcfd247 | ||
|
|
3f38f88bee | ||
|
|
88e36f9220 | ||
|
|
355b5f099e | ||
|
|
d049c7a175 | ||
|
|
0c1b92ce82 | ||
|
|
d2839ff264 | ||
|
|
24e36962aa | ||
|
|
da4ded1d7c | ||
|
|
d63d89bb86 | ||
|
|
bec89a8927 | ||
|
|
0d31254815 | ||
|
|
d848b1e05d | ||
|
|
c727c26612 | ||
|
|
9e6453c457 | ||
|
|
af84c3a547 | ||
|
|
04f73f422a | ||
|
|
7886e3f5d7 | ||
|
|
e8e251df67 | ||
|
|
1fb244e0a5 | ||
|
|
0bc5e0de9c | ||
|
|
6557c0604c | ||
|
|
f3a05ae1a3 | ||
|
|
6f0c7b1441 | ||
|
|
bb1007fae9 | ||
|
|
d5a8d23be6 | ||
|
|
b5dace7edd | ||
|
|
e39901dc6f | ||
|
|
963f465dc6 | ||
|
|
0ada161e69 | ||
|
|
ca5315dc0d | ||
|
|
536fe04942 | ||
|
|
14b5b031d3 | ||
|
|
397e947fac | ||
|
|
db2c7b799d | ||
|
|
bc005e8ac2 | ||
|
|
be75260bc0 | ||
|
|
f5f968df5c | ||
|
|
fd4c3e01e9 | ||
|
|
bd19371acf | ||
|
|
4f3a68c450 | ||
|
|
320778a3d2 | ||
|
|
b46c30af80 | ||
|
|
e2dbf8a03f | ||
|
|
21bdf4f9a3 | ||
|
|
dec8429c3d | ||
|
|
5455bc98f4 | ||
|
|
dd0a1e4972 | ||
|
|
525d7366eb | ||
|
|
9ca7f51d05 | ||
|
|
e17aabe322 | ||
|
|
b91fb97b1c | ||
|
|
64b7e511b1 | ||
|
|
d0c5f220bc | ||
|
|
dc3d702e01 | ||
|
|
e3e2404a1f | ||
|
|
3e536cb541 | ||
|
|
85b5ff7294 | ||
|
|
58cf8aa10c | ||
|
|
6f654f9180 | ||
|
|
b636b37e9a | ||
|
|
e8c32f6610 | ||
|
|
d87ead957e | ||
|
|
56402b95f3 | ||
|
|
7037d586ca | ||
|
|
3acfebb2fe | ||
|
|
77738c6356 | ||
|
|
fc26a5f40a | ||
|
|
7fe6f090e1 | ||
|
|
6297ea09f6 | ||
|
|
ef61ac7c6c | ||
|
|
0a72d58c25 | ||
|
|
decdf49937 | ||
|
|
a8b5151ce5 | ||
|
|
7b98a22fe1 | ||
|
|
0a8ac2e89c | ||
|
|
89b737d88a | ||
|
|
67591edf8f | ||
|
|
e2ca936487 | ||
|
|
4eb85dbdf9 |
No files matched your search
Generated
+11
@@ -830,6 +830,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -1117,6 +1127,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
|
||||
+4
-1
@@ -53,7 +53,10 @@ large_enum_variant = "allow"
|
||||
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
|
||||
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
|
||||
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
|
||||
# Drop this and the folder once a wgpu release has both fixes.
|
||||
# And gfx-rs/wgpu#10714: on Windows, Vulkan waits for the acquire fence only in
|
||||
# FIFO (VSync), where it was meant to pace frames; with VSync off it made every
|
||||
# frame wait for the GPU to finish the one before (#4604).
|
||||
# Drop this and the folder once a wgpu release has these fixes.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
developers, with one upstream fix applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
|
||||
developers, with upstream fixes applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## Pixelify Sans
|
||||
|
||||
@@ -63,6 +63,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
@@ -75,6 +77,10 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
|
||||
--ground claim the ground in every move (with --height 0 on a flat world: walking
|
||||
on it; higher, or over water: a no-fall or jesus cheat, to test an anticheat)
|
||||
--give SLOT:ID:COUNT,... creative-slot packets sent once joined (inventory menu slot, item
|
||||
registry id, count), to test what a server takes
|
||||
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
|
||||
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
";
|
||||
@@ -133,8 +139,13 @@ struct Options {
|
||||
attack_after: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
no_loaded: bool,
|
||||
height: f64,
|
||||
ground: bool,
|
||||
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
|
||||
give: Vec<(i16, i32, i32)>,
|
||||
/// --click: the block to open, then the slot, button and click type in its window.
|
||||
click: Option<([i32; 3], i16, i8, i32)>,
|
||||
name: String,
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
@@ -158,8 +169,11 @@ fn options() -> Result<Options, String> {
|
||||
attack_after: 5.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
no_loaded: false,
|
||||
height: 24.0,
|
||||
ground: false,
|
||||
give: Vec::new(),
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
brand: None,
|
||||
@@ -175,6 +189,10 @@ fn options() -> Result<Options, String> {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-loaded" {
|
||||
o.no_loaded = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--ground" {
|
||||
o.ground = true;
|
||||
continue;
|
||||
@@ -202,6 +220,32 @@ fn options() -> Result<Options, String> {
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
"--give" => {
|
||||
for part in value.split(',') {
|
||||
let n: Vec<&str> = part.split(':').collect();
|
||||
let parsed = match n[..] {
|
||||
[s, i, c] => s.parse().ok().zip(i.parse().ok()).zip(c.parse().ok()),
|
||||
_ => None,
|
||||
};
|
||||
let ((slot, item), count) = parsed.ok_or_else(|| number("SLOT:ID:COUNT"))?;
|
||||
o.give.push((slot, item, count));
|
||||
}
|
||||
}
|
||||
"--click" => {
|
||||
let bad = || number("X,Y,Z:SLOT:BUTTON:MODE");
|
||||
let n: Vec<&str> = value.split(':').collect();
|
||||
let [at, slot, button, mode] = n[..] else {
|
||||
return Err(bad());
|
||||
};
|
||||
let at: Vec<i32> = at
|
||||
.split(',')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|_| bad())?;
|
||||
let at = <[i32; 3]>::try_from(at).map_err(|_| bad())?;
|
||||
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
|
||||
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
|
||||
}
|
||||
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
|
||||
"--timer" => {
|
||||
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
|
||||
@@ -716,7 +760,9 @@ fn run_bot(
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
if !o.no_loaded {
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
}
|
||||
bot.deaf.store(o.no_read, Ordering::Relaxed);
|
||||
// What the costly floods need in hand (creative: the server takes the bot's word for it).
|
||||
let tool = match behaviour {
|
||||
@@ -724,6 +770,15 @@ fn run_bot(
|
||||
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
|
||||
_ => None,
|
||||
};
|
||||
for &(slot, item, count) in &o.give {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot,
|
||||
item: Some(Stack::new(item, count)),
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
if tool.is_some() {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
@@ -848,6 +903,38 @@ fn run_bot(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((at, slot, button, input)) = o.click {
|
||||
// Ticks of 50 ms: the block 3 s after joining, the click a second later.
|
||||
if tick == 60 {
|
||||
sequence += 1;
|
||||
send(
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
} else if tick == 80 {
|
||||
// The first window a player opens is 1.
|
||||
send(
|
||||
play::Serverbound::ContainerClick(play::ContainerClick {
|
||||
container: 1,
|
||||
state: 0,
|
||||
slot,
|
||||
button,
|
||||
input,
|
||||
changed: Vec::new(),
|
||||
carried: None,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
|
||||
@@ -3,179 +3,8 @@
|
||||
//! number formatting.
|
||||
use crate::text::{self, Style, Text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `Double.toString`.
|
||||
pub fn java_double(v: f64) -> String {
|
||||
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
|
||||
}
|
||||
/// `Float.toString`.
|
||||
pub fn java_float(v: f32) -> String {
|
||||
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
|
||||
}
|
||||
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
|
||||
if v.is_nan() {
|
||||
return "NaN".into();
|
||||
}
|
||||
if v.is_infinite() {
|
||||
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
|
||||
}
|
||||
if v == 0.0 {
|
||||
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
|
||||
}
|
||||
let a = v.abs();
|
||||
if (1.0e-3..1.0e7).contains(&a) {
|
||||
if plain.contains('.') {
|
||||
plain.to_owned()
|
||||
} else {
|
||||
format!("{plain}.0")
|
||||
}
|
||||
} else {
|
||||
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
|
||||
let mantissa = if mantissa.contains('.') {
|
||||
mantissa.to_owned()
|
||||
} else {
|
||||
format!("{mantissa}.0")
|
||||
};
|
||||
format!("{mantissa}E{exponent}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `SnbtGrammar.escapeControlCharacters`.
|
||||
fn escape_control(c: char) -> Option<&'static str> {
|
||||
Some(match c {
|
||||
'\u{8}' => "b",
|
||||
'\u{c}' => "f",
|
||||
'\n' => "n",
|
||||
'\r' => "r",
|
||||
'\t' => "t",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `StringTag.quoteAndEscape`.
|
||||
pub fn quote_and_escape(input: &str) -> String {
|
||||
let mut out = String::from(" ");
|
||||
let mut quote = '\0';
|
||||
for c in input.chars() {
|
||||
if c == '\\' {
|
||||
out.push_str("\\\\");
|
||||
} else if c != '"' && c != '\'' {
|
||||
match escape_control(c) {
|
||||
Some(e) => {
|
||||
out.push('\\');
|
||||
out.push_str(e);
|
||||
}
|
||||
None => out.push(c),
|
||||
}
|
||||
} else {
|
||||
if quote == '\0' {
|
||||
quote = if c == '"' { '\'' } else { '"' };
|
||||
}
|
||||
if quote == c {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if quote == '\0' {
|
||||
quote = '"';
|
||||
}
|
||||
out.replace_range(0..1, "e.to_string());
|
||||
out.push(quote);
|
||||
out
|
||||
}
|
||||
fn unquoted_key(key: &str) -> bool {
|
||||
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
|
||||
return false;
|
||||
}
|
||||
let mut chars = key.chars();
|
||||
let first_ok = chars
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
|
||||
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
|
||||
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
|
||||
}
|
||||
fn string_of(t: &horizon_world::nbt::JavaString) -> String {
|
||||
t.to_string_lossy()
|
||||
}
|
||||
|
||||
/// `Tag.toString` (StringTagVisitor).
|
||||
pub fn to_snbt(tag: &Tag) -> String {
|
||||
let mut out = String::new();
|
||||
visit(tag, &mut out);
|
||||
out
|
||||
}
|
||||
fn visit(tag: &Tag, out: &mut String) {
|
||||
match tag {
|
||||
Tag::End => out.push_str("END"),
|
||||
Tag::Byte(v) => out.push_str(&format!("{v}b")),
|
||||
Tag::Short(v) => out.push_str(&format!("{v}s")),
|
||||
Tag::Int(v) => out.push_str(&v.to_string()),
|
||||
Tag::Long(v) => out.push_str(&format!("{v}L")),
|
||||
Tag::Float(v) => {
|
||||
out.push_str(&java_float(f32::from_bits(*v)));
|
||||
out.push('f');
|
||||
}
|
||||
Tag::Double(v) => {
|
||||
out.push_str(&java_double(f64::from_bits(*v)));
|
||||
out.push('d');
|
||||
}
|
||||
Tag::String(s) => out.push_str("e_and_escape(&string_of(s))),
|
||||
Tag::ByteArray(a) => {
|
||||
out.push_str("[B;");
|
||||
out.push_str(
|
||||
&a.iter()
|
||||
.map(|v| format!("{}B", *v as i8))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
);
|
||||
out.push(']');
|
||||
}
|
||||
Tag::IntArray(a) => {
|
||||
out.push_str("[I;");
|
||||
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::LongArray(a) => {
|
||||
out.push_str("[L;");
|
||||
out.push_str(
|
||||
&a.iter()
|
||||
.map(|v| format!("{v}L"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
);
|
||||
out.push(']');
|
||||
}
|
||||
Tag::List(l) => {
|
||||
out.push('[');
|
||||
for (i, t) in l.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
visit(t, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Tag::Compound(c) => {
|
||||
out.push('{');
|
||||
let mut entries: Vec<(String, &Tag)> =
|
||||
c.iter().map(|(k, v)| (string_of(k), v)).collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (i, (k, v)) in entries.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
if unquoted_key(k) {
|
||||
out.push_str(k);
|
||||
} else {
|
||||
out.push_str("e_and_escape(k));
|
||||
}
|
||||
out.push(':');
|
||||
visit(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
use horizon_world::snbt_text::string_of;
|
||||
pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to_snbt};
|
||||
|
||||
// ---- the coloured form -----------------------------------------------------
|
||||
|
||||
|
||||
@@ -567,6 +567,34 @@ fn length(v: [f32; 2]) -> f32 {
|
||||
(v[0] * v[0] + v[1] * v[1]).sqrt()
|
||||
}
|
||||
|
||||
/// 8BitDo Ultimate 2 Wireless (`2dc8:6012`). gilrs' bundled SDL DB only has a
|
||||
/// Windows row for it; on Linux/Bluetooth the right stick is on ABS_Z/ABS_RX
|
||||
/// (`LeftZ`/`RightStickX`) until an SDL mapping matches.
|
||||
pub const BITDO_ULTIMATE2_VID_PID: (u16, u16) = (0x2dc8, 0x6012);
|
||||
|
||||
/// Stick axes after correcting pads whose right stick gilrs still exposes on
|
||||
/// `LeftZ`/`RightStickX` (no matching SDL row for this device version).
|
||||
/// `sdl_mapped` is true when gilrs already applied an SDL gamecontroller row.
|
||||
pub fn stick_axes(
|
||||
left: [f32; 2],
|
||||
right: [f32; 2],
|
||||
left_z: f32,
|
||||
vendor: Option<u16>,
|
||||
product: Option<u16>,
|
||||
sdl_mapped: bool,
|
||||
) -> ([f32; 2], [f32; 2]) {
|
||||
if !sdl_mapped
|
||||
&& vendor == Some(BITDO_ULTIMATE2_VID_PID.0)
|
||||
&& product == Some(BITDO_ULTIMATE2_VID_PID.1)
|
||||
{
|
||||
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
|
||||
// is the right trigger and must not drive look.
|
||||
(left, [left_z, right[0]])
|
||||
} else {
|
||||
(left, right)
|
||||
}
|
||||
}
|
||||
|
||||
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
|
||||
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
|
||||
/// clipped and the first movement past the deadzone is small.
|
||||
@@ -1178,6 +1206,38 @@ mod tests {
|
||||
assert_eq!(odd, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultimate2_right_stick_on_z_without_sdl() {
|
||||
let (left, right) = stick_axes(
|
||||
[0.2, 0.3],
|
||||
[0.7, -0.9],
|
||||
-0.5,
|
||||
Some(BITDO_ULTIMATE2_VID_PID.0),
|
||||
Some(BITDO_ULTIMATE2_VID_PID.1),
|
||||
false,
|
||||
);
|
||||
assert_eq!(left, [0.2, 0.3]);
|
||||
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
|
||||
let kept = stick_axes(
|
||||
[0.2, 0.3],
|
||||
[0.7, -0.9],
|
||||
-0.5,
|
||||
Some(BITDO_ULTIMATE2_VID_PID.0),
|
||||
Some(BITDO_ULTIMATE2_VID_PID.1),
|
||||
true,
|
||||
);
|
||||
assert_eq!(kept, ([0.2, 0.3], [0.7, -0.9]), "SDL row already remapped");
|
||||
let other = stick_axes(
|
||||
[0.0, 0.0],
|
||||
[1.0, 0.0],
|
||||
0.5,
|
||||
Some(0x045e),
|
||||
Some(0x028e),
|
||||
false,
|
||||
);
|
||||
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_deadzone_rescales() {
|
||||
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
|
||||
|
||||
@@ -10,6 +10,10 @@ pub struct KeyState {
|
||||
released_by_screen_when_down: bool,
|
||||
toggle: Option<bool>,
|
||||
keyboard: bool,
|
||||
/// Held down by a controller's toggle (Horizon's pad toggle-sneak, which
|
||||
/// vanilla has no option for): until it lets go, the mapping toggles as
|
||||
/// if its vanilla option were on, so a screen releases and restores it.
|
||||
pad_toggled: bool,
|
||||
}
|
||||
|
||||
impl KeyState {
|
||||
@@ -20,6 +24,7 @@ impl KeyState {
|
||||
released_by_screen_when_down: false,
|
||||
toggle: None,
|
||||
keyboard: true,
|
||||
pad_toggled: false,
|
||||
}
|
||||
}
|
||||
/// A `ToggleKeyMapping`; `keyboard` is whether the bound key is a keyboard key.
|
||||
@@ -43,16 +48,28 @@ impl KeyState {
|
||||
true
|
||||
}
|
||||
}
|
||||
/// The mapping toggles: its option is on, or a pad toggled it down.
|
||||
fn needs_toggle(&self, option: bool) -> bool {
|
||||
option || self.pad_toggled
|
||||
}
|
||||
/// `setDown`; `needs_toggle` is the mapping's option value.
|
||||
pub fn set_down(&mut self, down: bool, needs_toggle: bool) {
|
||||
if self.toggle.is_some() && needs_toggle {
|
||||
if self.toggle.is_some() && self.needs_toggle(needs_toggle) {
|
||||
if down {
|
||||
self.is_down = !self.is_down;
|
||||
// Toggled back up: the pad's toggle is over.
|
||||
self.pad_toggled &= self.is_down;
|
||||
}
|
||||
} else {
|
||||
self.is_down = down;
|
||||
}
|
||||
}
|
||||
/// A controller's toggle button: flips the mapping like `setDown(true)`
|
||||
/// with its option on, whatever the option says.
|
||||
pub fn pad_toggle(&mut self) {
|
||||
self.is_down = !self.is_down;
|
||||
self.pad_toggled = self.is_down;
|
||||
}
|
||||
/// `shouldSetOnIngameFocus` for a keyboard-bound, bound key.
|
||||
pub fn should_set_on_ingame_focus(&self, needs_toggle: bool) -> bool {
|
||||
self.keyboard && (self.toggle.is_none() || !needs_toggle)
|
||||
@@ -61,7 +78,8 @@ impl KeyState {
|
||||
pub fn release(&mut self, needs_toggle: bool) {
|
||||
if self.toggle.is_some() {
|
||||
// ToggleKeyMapping.release does not clear pending clicks.
|
||||
if needs_toggle && self.is_down || self.released_by_screen_when_down {
|
||||
if self.needs_toggle(needs_toggle) && self.is_down || self.released_by_screen_when_down
|
||||
{
|
||||
self.released_by_screen_when_down = true;
|
||||
}
|
||||
self.reset();
|
||||
@@ -70,7 +88,8 @@ impl KeyState {
|
||||
self.set_down(false, needs_toggle);
|
||||
}
|
||||
}
|
||||
/// `ToggleKeyMapping.reset`.
|
||||
/// `ToggleKeyMapping.reset`. A pad toggle stays on record, so the screen
|
||||
/// that released it restores it.
|
||||
pub fn reset(&mut self) {
|
||||
self.is_down = false;
|
||||
}
|
||||
@@ -83,7 +102,10 @@ impl KeyState {
|
||||
let Some(restore) = self.toggle else {
|
||||
return false;
|
||||
};
|
||||
let result = restore && needs_toggle && self.keyboard && self.released_by_screen_when_down;
|
||||
let result = restore
|
||||
&& self.needs_toggle(needs_toggle)
|
||||
&& self.keyboard
|
||||
&& self.released_by_screen_when_down;
|
||||
self.released_by_screen_when_down = false;
|
||||
result
|
||||
}
|
||||
@@ -138,6 +160,15 @@ impl ToggleKeys {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// A controller's toggle button bound to `slot` (the pad's toggle-sneak):
|
||||
/// a press with no screen open flips the mapping and clicks it, as a
|
||||
/// key press does with the mapping's toggle option on.
|
||||
pub fn pad_toggle(&mut self, slot: usize, screen_open: bool) {
|
||||
if !screen_open {
|
||||
self.0[slot].pad_toggle();
|
||||
self.0[slot].click();
|
||||
}
|
||||
}
|
||||
/// `MouseHandler.onButton` for a button bound to `slot`: only without a
|
||||
/// screen or overlay.
|
||||
pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) {
|
||||
@@ -167,6 +198,9 @@ impl ToggleKeys {
|
||||
pub fn respawn(&mut self) {
|
||||
for key in &mut self.0 {
|
||||
key.reset();
|
||||
// As with the vanilla option, the death screen closing after
|
||||
// this still restores a toggle it released.
|
||||
key.pad_toggled &= key.released_by_screen_when_down;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +231,57 @@ mod tests {
|
||||
keys.respawn();
|
||||
assert!(!keys.is_down(SPRINT));
|
||||
}
|
||||
/// #3061: the pad's toggle-sneak (vanilla's toggle crouch off) comes
|
||||
/// back after a screen, as the vanilla option's toggle does; a
|
||||
/// keyboard shift-click in the inventory does not drop it.
|
||||
#[test]
|
||||
fn pad_toggle_is_restored_after_a_screen() {
|
||||
let off = [false; 4];
|
||||
let mut keys = ToggleKeys::default();
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
assert!(keys.is_down(SNEAK));
|
||||
keys.screen_opened(off);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
keys.key(SNEAK, KeyAction::Press, true, false);
|
||||
keys.key(SNEAK, KeyAction::Release, true, false);
|
||||
keys.screen_closed(off);
|
||||
assert!(keys.is_down(SNEAK), "the pad's toggle is restored");
|
||||
// Toggled back up, it stays up through the next screen.
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
keys.screen_opened(off);
|
||||
keys.screen_closed(off);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
// A held key is not restored, as before.
|
||||
keys.key(SNEAK, KeyAction::Press, false, false);
|
||||
keys.screen_opened(off);
|
||||
keys.key(SNEAK, KeyAction::Release, true, false);
|
||||
keys.screen_closed(off);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
}
|
||||
/// After the pad's toggle is over (toggled up, or reset by a respawn
|
||||
/// with no screen to restore it), the shift key holds again.
|
||||
#[test]
|
||||
fn pad_toggle_ends_with_its_release() {
|
||||
let off = [false; 4];
|
||||
let mut keys = ToggleKeys::default();
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
keys.respawn();
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
keys.key(SNEAK, KeyAction::Press, false, false);
|
||||
keys.key(SNEAK, KeyAction::Release, false, false);
|
||||
assert!(!keys.is_down(SNEAK), "held, not toggled");
|
||||
// The death screen releases it and closes after the respawn: the
|
||||
// toggle comes back, as with the vanilla option.
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
keys.screen_opened(off);
|
||||
keys.respawn();
|
||||
keys.screen_closed(off);
|
||||
assert!(keys.is_down(SNEAK));
|
||||
// A press with a screen open does nothing.
|
||||
keys.pad_toggle(SNEAK, true);
|
||||
assert!(keys.is_down(SNEAK));
|
||||
}
|
||||
#[test]
|
||||
fn screen_release_restores_toggled_state() {
|
||||
let mut k = KeyState::toggle(true, true);
|
||||
|
||||
@@ -32,7 +32,7 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
|
||||
glam = "0.30"
|
||||
log = "0.4"
|
||||
miniz_oxide = "0.8"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
|
||||
png = "0.18"
|
||||
pollster = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -47,6 +47,12 @@ struct Mixer {
|
||||
listener_yaw: f32,
|
||||
listener_pitch: f32,
|
||||
limiter_peak: f32,
|
||||
/// The output device the stream was opened on (`Library.currentDeviceName`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_name: String,
|
||||
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_lost: bool,
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
@@ -167,6 +173,15 @@ pub struct Audio {
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: Option<String>,
|
||||
/// The other half of `lastSeenDevices`: every output device last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: Vec<String>,
|
||||
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -506,19 +521,30 @@ fn open_device(
|
||||
use cpal::SampleFormat as F;
|
||||
let config = device.default_output_config()?;
|
||||
let stream = match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer),
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
|
||||
other => Err(format!("unsupported sample format {other}").into()),
|
||||
}?;
|
||||
stream.play()?;
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
m.device_name = device.name().unwrap_or_default();
|
||||
m.device_lost = false;
|
||||
}
|
||||
Ok((stream, config))
|
||||
}
|
||||
/// The name of the system's default output device.
|
||||
#[cfg(feature = "audio")]
|
||||
fn default_output_name() -> Option<String> {
|
||||
cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
@@ -528,6 +554,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let mut scratch = Vec::new();
|
||||
let on_error = mixer.clone();
|
||||
let stream = device.build_output_stream(
|
||||
&config.config(),
|
||||
move |data: &mut [T], _| {
|
||||
@@ -541,7 +568,14 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
*out = T::from_sample(*s);
|
||||
}
|
||||
},
|
||||
|e| log::warn!("Horizon: audio stream error: {e}"),
|
||||
move |e| {
|
||||
log::warn!("Horizon: audio stream error: {e}");
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable)
|
||||
&& let Ok(mut m) = on_error.lock()
|
||||
{
|
||||
m.device_lost = true;
|
||||
}
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
@@ -637,6 +671,12 @@ impl Audio {
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: if live { default_output_name() } else { None },
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: if live { output_devices() } else { Vec::new() },
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -764,8 +804,64 @@ impl Audio {
|
||||
}
|
||||
self.engine.listening = self.record_heard;
|
||||
}
|
||||
/// `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.
|
||||
#[cfg(feature = "audio")]
|
||||
fn should_change_device(&mut self) -> bool {
|
||||
let (current, lost) = match self.backend.mixer.lock() {
|
||||
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
|
||||
Err(_) => return false,
|
||||
};
|
||||
if lost {
|
||||
log::info!("Horizon: audio device was lost");
|
||||
self.default_seen = default_output_name();
|
||||
self.devices_seen = output_devices();
|
||||
return true;
|
||||
}
|
||||
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 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 {
|
||||
return false;
|
||||
}
|
||||
self.default_seen = default.clone();
|
||||
let wanted = self.device_name.clone();
|
||||
let available = devices.contains(&wanted);
|
||||
self.devices_seen = devices;
|
||||
if wanted.is_empty() {
|
||||
let changed = default.as_deref().is_some_and(|d| d != current);
|
||||
if changed {
|
||||
log::info!("Horizon: system default audio device has changed");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
let changed = current != wanted && available;
|
||||
if changed {
|
||||
log::info!("Horizon: preferred audio device has become available");
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// `SoundManager.tick(paused)`, once per client tick.
|
||||
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
|
||||
#[cfg(feature = "audio")]
|
||||
{
|
||||
if self.should_change_device() {
|
||||
// SoundEngine.reload: stop every sound, open the output again.
|
||||
self.stop_all();
|
||||
let device = self.device_name.clone();
|
||||
if let Err(e) = self.set_device(&device) {
|
||||
log::warn!("Horizon: could not reopen the audio output: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
}
|
||||
@@ -801,6 +897,37 @@ impl Audio {
|
||||
let seed = self.random.next_long();
|
||||
self.play_seeded(event, at, volume, pitch, seed);
|
||||
}
|
||||
/// `ClientLevel.playLocalSound` with `distanceDelay`: a sound more than 10
|
||||
/// blocks from the camera starts `sqrt(distance) / 40` seconds later.
|
||||
pub fn play_distance_delayed(
|
||||
&mut self,
|
||||
event: &str,
|
||||
at: [f64; 3],
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
camera: [f64; 3],
|
||||
) {
|
||||
let distance_sqr: f64 = (0..3).map(|i| (at[i] - camera[i]).powi(2)).sum();
|
||||
if distance_sqr <= 100.0 {
|
||||
self.play(event, Some(at), volume, pitch);
|
||||
return;
|
||||
}
|
||||
let seed = self.random.next_long();
|
||||
if let Some(capture) = self.capture.as_mut() {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
}
|
||||
*self.heard.entry(event.to_owned()).or_default() += 1;
|
||||
self.sync_volumes();
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let source = self
|
||||
.source_override
|
||||
.unwrap_or_else(|| crate::settings::sound_source(&event));
|
||||
let instance =
|
||||
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
|
||||
let id = self.engine.add(instance, false);
|
||||
self.engine
|
||||
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
|
||||
}
|
||||
/// `play` for this client only: a sound a packet's handler plays
|
||||
/// (`playLocalSound`), which the guests play from that packet themselves.
|
||||
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
|
||||
|
||||
@@ -24,6 +24,9 @@ struct Out {
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
|
||||
return border_color(in);
|
||||
}
|
||||
fn border_color(in: Out) -> vec4<f32> {
|
||||
let color = textureSample(forcefield, forcefield_sampler, in.uv);
|
||||
if color.a == 0.0 {
|
||||
discard;
|
||||
|
||||
@@ -21,6 +21,8 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
/// `GuiMessageTag.CHAT_MODIFIED_INDICATOR_COLOR`.
|
||||
const MODIFIED_INDICATOR: u32 = 0x60_6060;
|
||||
/// A player message's signature (MessageSignature), which names it to a
|
||||
/// server's deletion.
|
||||
pub type Signature = horizon_protocol::secure_chat::Signature;
|
||||
@@ -169,7 +171,7 @@ impl ChatOptions {
|
||||
struct Message {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
source: ChatSource,
|
||||
/// `GuiMessage.signature`: a signed player message's, for deletion.
|
||||
signature: Option<Box<Signature>>,
|
||||
@@ -179,7 +181,46 @@ struct Message {
|
||||
struct Line {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
}
|
||||
|
||||
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
|
||||
/// over it, and for a modified message the icon after each line.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Tag {
|
||||
pub color: u32,
|
||||
/// `GuiMessageTag.Icon.CHAT_MODIFIED`.
|
||||
pub icon: bool,
|
||||
pub text: Text,
|
||||
}
|
||||
impl Tag {
|
||||
/// `GuiMessageTag.systemSinglePlayer`, in `color`.
|
||||
pub fn system_single_player(color: u32) -> Self {
|
||||
Self {
|
||||
color,
|
||||
icon: false,
|
||||
text: Text::translate("chat.tag.system_single_player", &[]),
|
||||
}
|
||||
}
|
||||
/// `GuiMessageTag.chatNotSecure`.
|
||||
pub fn not_secure() -> Self {
|
||||
Self {
|
||||
color: SYSTEM_INDICATOR,
|
||||
icon: false,
|
||||
text: Text::translate("chat.tag.not_secure", &[]),
|
||||
}
|
||||
}
|
||||
/// `GuiMessageTag.chatModified`: the tooltip adds the original content in grey.
|
||||
pub fn modified(original_content: &str) -> Self {
|
||||
Self {
|
||||
color: MODIFIED_INDICATOR,
|
||||
icon: true,
|
||||
text: Text::empty()
|
||||
.append(Text::translate("chat.tag.modified", &[]))
|
||||
.append(Text::literal("\n"))
|
||||
.append(Text::literal(original_content).with_color(text::GRAY)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
@@ -236,7 +277,8 @@ impl Chat {
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
|
||||
let tag = indicator.map(|color| std::sync::Arc::new(Tag::system_single_player(color)));
|
||||
self.add_message(&atlas.font, content, tag, source, ticks, focused);
|
||||
}
|
||||
/// `add` of a player message with its signature, which a server's
|
||||
/// deletion names it by (`GuiMessage.signature`).
|
||||
@@ -245,11 +287,13 @@ impl Chat {
|
||||
atlas: &HudAtlas,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
tag: Option<Tag>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
|
||||
self.add_player_message(&atlas.font, content, signature, tag, ticks, focused);
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn add_signed_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
@@ -257,13 +301,25 @@ impl Chat {
|
||||
signature: Option<Signature>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_player_message(font, content, signature, None, ticks, focused);
|
||||
}
|
||||
/// `ChatComponent.addPlayerMessage`: the tag the message's trust level made.
|
||||
fn add_player_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
tag: Option<Tag>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator: None,
|
||||
tag: tag.map(std::sync::Arc::new),
|
||||
source: ChatSource::Player,
|
||||
signature: signature.map(Box::new),
|
||||
},
|
||||
@@ -274,7 +330,7 @@ impl Chat {
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
source: ChatSource,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
@@ -284,7 +340,7 @@ impl Chat {
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
tag,
|
||||
source,
|
||||
signature: None,
|
||||
},
|
||||
@@ -307,7 +363,13 @@ impl Chat {
|
||||
self.received += 1;
|
||||
}
|
||||
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
|
||||
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
|
||||
9 + 4 + 2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let width = self.max_width() - icon;
|
||||
let content = self.displayed(&message.content);
|
||||
for line in font.wrap_components(&content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
@@ -319,7 +381,7 @@ impl Chat {
|
||||
Line {
|
||||
added: message.added,
|
||||
content: line,
|
||||
indicator: message.indicator,
|
||||
tag: message.tag.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -416,7 +478,9 @@ impl Chat {
|
||||
*message = Message {
|
||||
added: message.added,
|
||||
content: Self::deleted_marker(),
|
||||
indicator: Some(SYSTEM_INDICATOR),
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
SYSTEM_INDICATOR,
|
||||
))),
|
||||
source: ChatSource::Server,
|
||||
signature: None,
|
||||
};
|
||||
@@ -540,8 +604,8 @@ impl Chat {
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
if let Some(indicator) = line.indicator {
|
||||
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
|
||||
if let Some(tag) = &line.tag {
|
||||
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
@@ -702,12 +766,91 @@ impl Chat {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Whether the cursor is on a line's indicator bar while chat is focused
|
||||
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
|
||||
/// message tag's text.
|
||||
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
|
||||
self.line_at(height, x, y)
|
||||
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
|
||||
/// `DrawingFocusedGraphicsAccess.handleTag` and `handleTagIcon` with the
|
||||
/// cursor at GUI position `(x, y)` while chat is focused: the tooltip of
|
||||
/// the tag under it (its bar, or its icon) and the icons to draw, those
|
||||
/// under the cursor and those of the message the cursor is over.
|
||||
pub fn focused_tags(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> (Option<Text>, Vec<GuiDraw>) {
|
||||
let o = &self.options;
|
||||
let scale = o.scale as f32;
|
||||
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
let (mouse_x, mouse_y) = (x / f64::from(scale) - 4.0, y / f64::from(scale));
|
||||
let inside = |left: i32, top: i32, right: i32, bottom: i32| {
|
||||
mouse_x >= f64::from(left)
|
||||
&& mouse_x < f64::from(right)
|
||||
&& mouse_y >= f64::from(top)
|
||||
&& mouse_y < f64::from(bottom)
|
||||
};
|
||||
let visible = self
|
||||
.lines
|
||||
.len()
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(true));
|
||||
let mut tooltip = None;
|
||||
let mut icons = Vec::new();
|
||||
let mut hovered_message = false;
|
||||
for i in (0..visible).rev() {
|
||||
let index = i + self.scroll;
|
||||
let line = &self.lines[index];
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
let top = bottom - entry_height;
|
||||
let width = font.width_text(&line.content).ceil() as i32;
|
||||
hovered_message |= inside(0, top, width, bottom);
|
||||
// GuiMessage.Line.endOfEntry: the next newer line is another message's.
|
||||
let end_of_entry = index == 0
|
||||
|| match (&line.tag, &self.lines[index - 1].tag) {
|
||||
(Some(a), Some(b)) => !std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
let force_icon = end_of_entry && hovered_message;
|
||||
if end_of_entry {
|
||||
hovered_message = false;
|
||||
}
|
||||
let Some(tag) = &line.tag else { continue };
|
||||
if inside(-4, top, -2, bottom) {
|
||||
tooltip = Some(tag.text.clone());
|
||||
}
|
||||
if tag.icon {
|
||||
let (left, text_bottom) = (width + 4, bottom - bottom_to_text + 9);
|
||||
let icon_top = text_bottom - 9 - 1;
|
||||
let over = inside(left, icon_top, left + 9, text_bottom);
|
||||
if over {
|
||||
tooltip = Some(tag.text.clone());
|
||||
}
|
||||
if force_icon || over {
|
||||
icons.push(GuiDraw::Sprite {
|
||||
sprite: "icon/chat_modified".into(),
|
||||
x: left,
|
||||
y: icon_top,
|
||||
w: 9,
|
||||
h: 9,
|
||||
region: None,
|
||||
color: hud_layout::WHITE,
|
||||
blend: hud_layout::Blend::Textured,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if icons.is_empty() {
|
||||
return (tooltip, icons);
|
||||
}
|
||||
let mut draws = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
draws.extend(icons);
|
||||
draws.push(GuiDraw::Pop);
|
||||
(tooltip, draws)
|
||||
}
|
||||
/// Plain text of the newest messages (for tests and the control socket).
|
||||
pub fn recent_lines(&self, n: usize) -> Vec<String> {
|
||||
|
||||
@@ -33,7 +33,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
Line {
|
||||
added: 0,
|
||||
content,
|
||||
indicator: None,
|
||||
tag: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -557,7 +557,7 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
|
||||
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
|
||||
assert!(chat.deletions.is_empty());
|
||||
let line = &chat.lines[1];
|
||||
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
|
||||
assert_eq!(line.tag.as_ref().map(|t| t.color), Some(SYSTEM_INDICATOR));
|
||||
assert_eq!(line.added, 100, "the marker keeps the message's time");
|
||||
assert!(
|
||||
line.content
|
||||
|
||||
@@ -223,6 +223,35 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
|
||||
let file = world.join("datapacks.json");
|
||||
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
|
||||
}
|
||||
/// Re-Create World's packs from a saved world: a new folder holding a copy of
|
||||
/// its `datapacks` (`CreateWorldScreen.createTempDataPackDirFromExistingWorld`; `None` when the
|
||||
/// world has none), and the selection its `datapacks.json` makes, lowest
|
||||
/// priority first (`WorldOpenFlows.recreateWorldData` keeps the world's `DataConfiguration`).
|
||||
/// The built-in pack is selected unless the world turned it off.
|
||||
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
|
||||
let packs = discover(world);
|
||||
let mut selected = enabled_ids(world, &packs);
|
||||
let vanilla_off = std::fs::read(world.join("datapacks.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.and_then(|v| v["disabled"].as_array().cloned())
|
||||
.is_some_and(|a| a.iter().any(|id| id.as_str() == Some("vanilla")));
|
||||
if !vanilla_off {
|
||||
selected.insert(0, "vanilla".into());
|
||||
}
|
||||
if packs.is_empty() {
|
||||
return (None, selected);
|
||||
}
|
||||
let dir = temp_folder().ok().and_then(|dir| {
|
||||
if copy(&world.join("datapacks"), &dir).is_ok() {
|
||||
Some(dir)
|
||||
} else {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
None
|
||||
}
|
||||
});
|
||||
(dir, selected)
|
||||
}
|
||||
/// A file, or a folder and everything in it.
|
||||
fn copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
|
||||
|
||||
@@ -23,6 +23,7 @@ struct Globals {
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
filtering: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -131,7 +132,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = overlay >> 16u;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
@@ -224,6 +225,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
}
|
||||
// CRUMBLING_UV_END
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return entity_color(in, front);
|
||||
}
|
||||
fn entity_color(in: Out, front: bool) -> vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
if in.overlay == 11u || in.overlay == 12u {
|
||||
@@ -271,6 +275,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
|
||||
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
|
||||
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
|
||||
return shadow_color(in);
|
||||
}
|
||||
fn shadow_color(in: Out) -> vec4<f32> {
|
||||
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
color *= in.light_color;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
@@ -306,21 +313,31 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
let added = color.rgb * color.a;
|
||||
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
|
||||
// (the sun), terrain.wgsl's mark for the lower ones (the moon).
|
||||
let mark = 1.0 - 0.7 * f32(4u - min(in.tier, 4u)) / 4.0;
|
||||
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
|
||||
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
|
||||
// as breaking::stage_light packs them.
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
fn energy_swirl_base(in: Out) -> vec4<f32> {
|
||||
let bytes = round(in.color * 255.0);
|
||||
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
|
||||
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
|
||||
if color.a < 0.1 { discard; }
|
||||
return vec4(color.rgb * (128.0 / 255.0), color.a);
|
||||
}
|
||||
fn energy_swirl_fog(in: Out) -> f32 {
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
return f * globals.fog_color.a;
|
||||
}
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = energy_swirl_base(in);
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
@@ -449,6 +466,9 @@ struct WeatherTime { values: vec4<f32> }
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
|
||||
return weather_color(in);
|
||||
}
|
||||
fn weather_color(in: Out) -> vec4<f32> {
|
||||
// Repeat within this sprite, never across the atlas. The atlas uses nearest
|
||||
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
|
||||
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
|
||||
|
||||
@@ -40,7 +40,7 @@ mod entity_outline;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_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,
|
||||
};
|
||||
@@ -84,6 +84,8 @@ mod native_pack;
|
||||
mod native_post;
|
||||
mod native_sky;
|
||||
mod occlusion;
|
||||
mod oit;
|
||||
mod oit_features;
|
||||
mod pack_atlas;
|
||||
pub mod pack_draw;
|
||||
mod pack_entities;
|
||||
@@ -224,6 +226,8 @@ struct Globals {
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
|
||||
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
|
||||
/// x: `Options.textureFiltering` (0 none, 1 RGSS, 2 anisotropic); y: the sprite arrays' width.
|
||||
filtering: [f32; 4],
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
struct SectionGpu {
|
||||
@@ -542,6 +546,10 @@ pub struct Renderer {
|
||||
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`, for a shader pack's terrain.
|
||||
terrain_sprites: [f64; 2],
|
||||
sprite_sampler: wgpu::Sampler,
|
||||
/// `Options.textureFiltering`: 0 none, 1 RGSS, 2 anisotropic.
|
||||
texture_filtering: u32,
|
||||
/// The anisotropic sampler's clamp (`Options.maxAnisotropyValue`); 1 unless anisotropic.
|
||||
max_anisotropy: u16,
|
||||
lightmap_view: wgpu::TextureView,
|
||||
lightmap_sampler: wgpu::Sampler,
|
||||
animations: animate::SpriteAnimator,
|
||||
@@ -672,6 +680,10 @@ pub struct Renderer {
|
||||
pub gamma: f32,
|
||||
/// Optional full-light lightmap (Horizon Extras).
|
||||
pub fullbright: bool,
|
||||
/// The Improved transparency option (`GameRenderer.useImprovedTransparency`).
|
||||
pub improved_transparency: bool,
|
||||
/// Its passes and targets, made when first drawn with.
|
||||
oit: Option<oit::Oit>,
|
||||
/// Advance animated block textures (Horizon setting; vanilla always does).
|
||||
pub animated_textures: bool,
|
||||
present_modes: Vec<wgpu::PresentMode>,
|
||||
@@ -723,6 +735,36 @@ fn sprite_views(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The terrain sprites' sampler for a Texture Filtering mode. None keeps the nearest sampler;
|
||||
/// RGSS and anisotropic take LevelRenderer.chunkLayerSampler's linear one (clamped to the edge),
|
||||
/// with `anisotropy` samples when it is above 1 (wgpu then needs a linear mipmap filter too).
|
||||
fn sprite_sampler_descriptor(filtering: u32, anisotropy: u16) -> wgpu::SamplerDescriptor<'static> {
|
||||
if filtering == 0 {
|
||||
return wgpu::SamplerDescriptor {
|
||||
label: Some("Block sprites"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
wgpu::SamplerDescriptor {
|
||||
label: Some("Block sprites (filtered)"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
anisotropy_clamp: anisotropy,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `Options.maxAnisotropyValue` for a `maxAnisotropyBit`: `1 << bit`, at most wgpu's 16.
|
||||
fn sampler_anisotropy(bit: i32) -> u16 {
|
||||
1u16 << bit.clamp(0, 4)
|
||||
}
|
||||
/// terrain.wgsl's group 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn terrain_group(
|
||||
@@ -1119,6 +1161,7 @@ async fn open_backend(
|
||||
// stand-in neither clamps nor drops NaN.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| oit::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
@@ -1701,15 +1744,7 @@ impl Renderer {
|
||||
..Default::default()
|
||||
});
|
||||
let sprite_views = sprite_views(&sprite_textures, &sprite_unused, None);
|
||||
let sprite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Block sprites"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
let sprite_sampler = device.create_sampler(&sprite_sampler_descriptor(0, 1));
|
||||
let lightmap = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Lightmap"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -2728,7 +2763,7 @@ impl Renderer {
|
||||
}
|
||||
cull
|
||||
});
|
||||
let depth = Self::depth(&device, &config, cull.as_ref());
|
||||
let depth = Self::depth(&device, &config);
|
||||
let world_post = world_post::Post::new(&device, config.format.remove_srgb_suffix());
|
||||
let uploads = upload::Uploads::new(&device);
|
||||
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
|
||||
@@ -2840,6 +2875,8 @@ impl Renderer {
|
||||
terrain_layout: bindings,
|
||||
terrain_sprites: sprite_constants.map(|(_, v)| v),
|
||||
sprite_sampler,
|
||||
texture_filtering: 0,
|
||||
max_anisotropy: 1,
|
||||
lightmap_view,
|
||||
lightmap_sampler,
|
||||
animations,
|
||||
@@ -2914,6 +2951,8 @@ impl Renderer {
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
gamma: 0.5,
|
||||
improved_transparency: false,
|
||||
oit: None,
|
||||
fullbright: false,
|
||||
animated_textures: true,
|
||||
present_modes,
|
||||
@@ -2998,13 +3037,8 @@ impl Renderer {
|
||||
self.surface = Some(surface);
|
||||
Ok(())
|
||||
}
|
||||
/// The world's depth, which occlusion culling's test may read.
|
||||
fn depth(
|
||||
device: &wgpu::Device,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
cull: Option<&cull::Cull>,
|
||||
) -> wgpu::TextureView {
|
||||
let read = cull.is_some_and(|c| c.depth_test() == cull::DepthTest::InShader);
|
||||
/// The world's depth, which occlusion culling's test and post effects may read.
|
||||
fn depth(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon depth"),
|
||||
@@ -3017,11 +3051,11 @@ impl Renderer {
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: if read {
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING
|
||||
} else {
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
},
|
||||
// TEXTURE_BINDING: a post effect may read the depth buffer.
|
||||
// COPY_SRC: improved transparency copies it.
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
@@ -3085,7 +3119,7 @@ impl Renderer {
|
||||
} else {
|
||||
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
|
||||
}
|
||||
self.depth = Self::depth(&self.device, &self.config, self.cull.as_ref());
|
||||
self.depth = Self::depth(&self.device, &self.config);
|
||||
}
|
||||
/// Whether sections are drawn as one index list per frame (`flat`).
|
||||
pub fn flat_draws(&self) -> bool {
|
||||
@@ -3805,6 +3839,44 @@ impl Renderer {
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
self.pack_post.as_ref().map(|p| p.0)
|
||||
}
|
||||
/// The Texture Filtering and Max Anisotropy options (`filtering`: 0 none, 1 RGSS, 2
|
||||
/// anisotropic; `bit`: `maxAnisotropyBit`). None is the nearest sampler terrain always used;
|
||||
/// the others take LevelRenderer's chunk layer sampler (linear, clamped, `bit` anisotropy
|
||||
/// when anisotropic) and terrain.wgsl's sampleNearest / sampleRGSS.
|
||||
pub fn set_texture_filtering(&mut self, filtering: i32, bit: i32) {
|
||||
let filtering = filtering.clamp(0, 2) as u32;
|
||||
let anisotropy = if filtering == 2 {
|
||||
sampler_anisotropy(bit)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if filtering == self.texture_filtering && anisotropy == self.max_anisotropy {
|
||||
return;
|
||||
}
|
||||
self.texture_filtering = filtering;
|
||||
self.max_anisotropy = anisotropy;
|
||||
self.sprite_sampler = self
|
||||
.device
|
||||
.create_sampler(&sprite_sampler_descriptor(filtering, anisotropy));
|
||||
self.rebuild_terrain_group();
|
||||
}
|
||||
fn rebuild_terrain_group(&mut self) {
|
||||
let views = sprite_views(
|
||||
&self.sprite_textures,
|
||||
&self.sprite_unused,
|
||||
Some(self.mipmaps + 1),
|
||||
);
|
||||
self.group = terrain_group(
|
||||
&self.device,
|
||||
"Horizon terrain resources",
|
||||
&self.terrain_layout,
|
||||
&self.globals,
|
||||
&views,
|
||||
&self.sprite_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
}
|
||||
/// The Mipmap Levels option: block textures sample at most `levels`
|
||||
/// reduced copies (0: none).
|
||||
pub fn set_mipmap_levels(&mut self, levels: u32) {
|
||||
@@ -3818,17 +3890,7 @@ impl Renderer {
|
||||
return;
|
||||
}
|
||||
self.mipmaps = levels;
|
||||
let views = sprite_views(&self.sprite_textures, &self.sprite_unused, Some(levels + 1));
|
||||
self.group = terrain_group(
|
||||
&self.device,
|
||||
"Horizon terrain resources",
|
||||
&self.terrain_layout,
|
||||
&self.globals,
|
||||
&views,
|
||||
&self.sprite_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
self.rebuild_terrain_group();
|
||||
}
|
||||
/// The views `render` draws `frame` from.
|
||||
pub fn views(&self, frame: &Frame) -> ViewSet {
|
||||
@@ -4148,6 +4210,12 @@ impl Renderer {
|
||||
lights
|
||||
},
|
||||
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
|
||||
filtering: [
|
||||
self.texture_filtering as f32,
|
||||
self.sprite_textures[0].width() as f32,
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
@@ -4700,6 +4768,13 @@ impl Renderer {
|
||||
);
|
||||
entities = &self.pack_entity_rest;
|
||||
}
|
||||
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
|
||||
// path.
|
||||
let oit_on = self.improved_transparency
|
||||
&& !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.is_none()
|
||||
&& oit::supported(&self.device);
|
||||
let [
|
||||
entity_quads,
|
||||
text_quads,
|
||||
@@ -4710,9 +4785,14 @@ impl Renderer {
|
||||
shadow_quads,
|
||||
translucent_particle_quads,
|
||||
translucent_item_quads,
|
||||
] = self
|
||||
.entity_batch
|
||||
.update(entities, frame.additive, frame.hand, frame.see_through);
|
||||
oit_entity_quads,
|
||||
] = self.entity_batch.update(
|
||||
entities,
|
||||
frame.additive,
|
||||
frame.hand,
|
||||
frame.see_through,
|
||||
oit_on,
|
||||
);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
@@ -4722,7 +4802,11 @@ impl Renderer {
|
||||
+ swirl_quads
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads;
|
||||
+ translucent_item_quads
|
||||
+ oit_entity_quads;
|
||||
// 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;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
@@ -4972,7 +5056,7 @@ impl Renderer {
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
let keep_depth = pack_reads_world || (taa && self.world_post.taa);
|
||||
let keep_depth = pack_reads_world || (taa && self.world_post.taa) || oit_on;
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
@@ -5025,6 +5109,46 @@ impl Renderer {
|
||||
if culling && let Some(cull) = &mut self.cull {
|
||||
cull.bind_depth(&self.device, world_depth);
|
||||
}
|
||||
if oit_on {
|
||||
let size = self
|
||||
.world_post
|
||||
.size()
|
||||
.filter(|_| scaled.is_some())
|
||||
.unwrap_or([self.config.width, self.config.height]);
|
||||
let (device, layout, sprites) =
|
||||
(&self.device, &self.terrain_layout, self.terrain_sprites);
|
||||
let clouds = [0, 1].map(|i| self.cloud_pipeline.get_bind_group_layout(i));
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let flat = self
|
||||
.flat
|
||||
.as_ref()
|
||||
.map(|f| f.translucent.get_bind_group_layout(1));
|
||||
let features = [
|
||||
self.entity_pipeline.get_bind_group_layout(0),
|
||||
self.weather.pipeline().get_bind_group_layout(1),
|
||||
self.border_pipeline.get_bind_group_layout(0),
|
||||
];
|
||||
self.oit
|
||||
.get_or_insert_with(|| {
|
||||
oit::Oit::new(
|
||||
device,
|
||||
layout,
|
||||
flat.as_ref(),
|
||||
[&features[0], &features[1], &features[2]],
|
||||
[&clouds[0], &clouds[1]],
|
||||
sprites,
|
||||
format,
|
||||
)
|
||||
})
|
||||
.prepare(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
size,
|
||||
&views.primary().projection,
|
||||
[water_mask_quads > 0, cloud_quads > 0],
|
||||
);
|
||||
}
|
||||
let oit = self.oit.as_ref().filter(|_| oit_on);
|
||||
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
|
||||
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
|
||||
wgpu::Color::BLACK
|
||||
@@ -5262,29 +5386,29 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if text_quads > 0 {
|
||||
if text_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.text_polygon_offset_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 {
|
||||
if translucent_item_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.translucent_item_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_item_quads * 6,
|
||||
((world_quads - translucent_item_quads) * 4) as i32,
|
||||
((base_quads - translucent_item_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !pack_draws_weather {
|
||||
if !pack_draws_weather && !oit_on {
|
||||
self.weather.draw(&mut pass, &self.entity_group);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
if crumbling_quads > 0 {
|
||||
if crumbling_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5295,13 +5419,13 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if shadow_quads > 0 {
|
||||
if shadow_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.shadow_blob_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..shadow_quads * 6,
|
||||
((world_quads
|
||||
((base_quads
|
||||
- translucent_item_quads
|
||||
- translucent_particle_quads
|
||||
- shadow_quads)
|
||||
@@ -5312,7 +5436,7 @@ impl Renderer {
|
||||
}
|
||||
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
|
||||
// before translucent terrain, so no water shows inside the boat.
|
||||
if water_mask_quads > 0 {
|
||||
if water_mask_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.water_mask_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5324,13 +5448,13 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.lightning.bolts.is_empty() {
|
||||
if !frame.lightning.bolts.is_empty() && !oit_on {
|
||||
pass.set_pipeline(&self.lightning_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
self.lightning.draw(&mut pass, &frame.lightning);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if swirl_quads > 0 {
|
||||
if swirl_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.energy_swirl_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5346,7 +5470,7 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if additive_quads > 0 {
|
||||
if additive_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.additive_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5369,6 +5493,159 @@ impl Renderer {
|
||||
match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
// The pack draws translucent terrain after its deferred programs.
|
||||
_ if pack_draws_water => {}
|
||||
// LevelRenderer.executeOit in place of the classic blend; what
|
||||
// follows draws over its composite in a pass of its own.
|
||||
flat if let Some(oit) = oit => {
|
||||
drop(pass);
|
||||
// LevelRenderer.executeOitWaterMask and CloudRenderer.renderOit.
|
||||
let mask = |pass: &mut wgpu::RenderPass<'_>| {
|
||||
pass.set_pipeline(&self.water_mask_pipeline);
|
||||
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(..));
|
||||
pass.draw_indexed(
|
||||
0..water_mask_quads * 6,
|
||||
((entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads)
|
||||
* 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
};
|
||||
let clouds =
|
||||
|pass: &mut wgpu::RenderPass<'_>, pipeline: &wgpu::RenderPipeline| {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(1, &self.cloud_group, &[]);
|
||||
pass.set_index_buffer(
|
||||
self.indices.slice(..),
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
|
||||
pass.draw_indexed(0..cloud_quads * 6, 0, 0..1);
|
||||
};
|
||||
// "Features, World Border, Weather": what follows the opaque
|
||||
// world in the classic pass, one stage at a time.
|
||||
let features = |pass: &mut wgpu::RenderPass<'_>, stage: oit::Stage| {
|
||||
let f = oit.features();
|
||||
let s = stage as usize;
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
// Quads of a material, `first` quads into the buffer.
|
||||
let quads = |pass: &mut wgpu::RenderPass<'_>,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
count: u32,
|
||||
first: u32| {
|
||||
if count > 0 {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.draw_indexed(0..count * 6, (first * 4) as i32, 0..1);
|
||||
}
|
||||
};
|
||||
quads(pass, &f.text[s], text_quads, entity_quads);
|
||||
quads(
|
||||
pass,
|
||||
&f.crumbling[s],
|
||||
crumbling_quads,
|
||||
entity_quads + text_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.crumbling[s],
|
||||
translucent_crumbling_quads,
|
||||
entity_quads + text_quads + crumbling_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.swirl[s],
|
||||
swirl_quads,
|
||||
entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.shadow[s],
|
||||
shadow_quads,
|
||||
base_quads
|
||||
- translucent_item_quads
|
||||
- translucent_particle_quads
|
||||
- shadow_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.entity[s],
|
||||
translucent_particle_quads,
|
||||
base_quads - translucent_item_quads - translucent_particle_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.entity[s],
|
||||
translucent_item_quads,
|
||||
base_quads - translucent_item_quads,
|
||||
);
|
||||
// Translucent, emissive, eyes and breeze wind models:
|
||||
// the same fragment, their light and shading are in
|
||||
// the vertices.
|
||||
quads(pass, &f.entity[s], oit_entity_quads, base_quads);
|
||||
if !frame.lightning.bolts.is_empty() {
|
||||
pass.set_pipeline(&f.lightning[s]);
|
||||
self.lightning.draw(pass, &frame.lightning);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
}
|
||||
quads(pass, &f.additive[s], additive_quads, world_quads);
|
||||
self.weather
|
||||
.draw_with(pass, &f.weather[s], &self.entity_group);
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
|
||||
pass.set_pipeline(&f.border[s]);
|
||||
pass.set_bind_group(0, &self.border_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.border_vertices.slice(..));
|
||||
for &side in &b.sides {
|
||||
let first = side as u32 * 6;
|
||||
pass.draw_indexed(first..first + 6, 0, 0..1);
|
||||
}
|
||||
}
|
||||
};
|
||||
let extras = oit::Extras {
|
||||
water_mask: (water_mask_quads > 0).then_some(&mask as _),
|
||||
clouds: (cloud_quads > 0).then_some(&clouds as _),
|
||||
features: Some(&features),
|
||||
};
|
||||
oit.run(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
&|pass, stage| {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
// Flat section draws take the flat pipelines.
|
||||
if let Some((flat, pipeline)) = flat.zip(oit.flat(stage)) {
|
||||
pass.set_pipeline(pipeline);
|
||||
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(oit.terrain(stage));
|
||||
pass.set_index_buffer(
|
||||
self.indices.slice(..),
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
self.draw_sections(
|
||||
pass,
|
||||
&self.indirect,
|
||||
0,
|
||||
solid_draws,
|
||||
translucent_draws,
|
||||
);
|
||||
},
|
||||
&extras,
|
||||
);
|
||||
pass = world_pass_after_culling(&mut encoder, world_view, world_depth, true);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
|
||||
flat.draw(&mut pass, flat.translucent_calls(), |p| {
|
||||
@@ -5394,15 +5671,14 @@ impl Renderer {
|
||||
// translucent particle layer draws after translucent terrain —
|
||||
// the quads write depth, so before it they would hide the glass
|
||||
// or water behind them.
|
||||
if translucent_particle_quads > 0 {
|
||||
if translucent_particle_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
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(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_particle_quads * 6,
|
||||
((world_quads - translucent_item_quads - translucent_particle_quads) * 4)
|
||||
as i32,
|
||||
((base_quads - translucent_item_quads - translucent_particle_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -5414,7 +5690,7 @@ impl Renderer {
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
// LevelRenderer: the world border after translucent terrain and weather.
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
|
||||
pass.set_pipeline(&self.border_pipeline);
|
||||
pass.set_bind_group(0, &self.border_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -5425,7 +5701,7 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_crumbling_quads > 0 {
|
||||
if translucent_crumbling_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5435,7 +5711,7 @@ impl Renderer {
|
||||
0..1,
|
||||
);
|
||||
}
|
||||
if cloud_quads > 0 && !pack_draws_clouds {
|
||||
if cloud_quads > 0 && !pack_draws_clouds && !oit_on {
|
||||
pass.set_pipeline(&self.cloud_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(1, &self.cloud_group, &[]);
|
||||
@@ -5633,6 +5909,8 @@ impl Renderer {
|
||||
&target_texture,
|
||||
&view_target,
|
||||
[self.config.width, self.config.height],
|
||||
// The scaled world pass drew its own depth buffer.
|
||||
scaled.is_none().then_some(&self.depth),
|
||||
&frame.post_effects,
|
||||
);
|
||||
}
|
||||
@@ -6225,9 +6503,9 @@ mod tests {
|
||||
.flat_map(|(layer, id)| quad(layer, id))
|
||||
.collect();
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
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]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -6299,8 +6577,31 @@ mod tests {
|
||||
&mut files,
|
||||
);
|
||||
assert!(files.len() >= 10, "{files:?}");
|
||||
// Improved transparency's files are appended to terrain.wgsl and entity.wgsl.
|
||||
let mut sources: Vec<(std::path::PathBuf, String)> = Vec::new();
|
||||
for path in files {
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if name == "oit.wgsl" {
|
||||
// The shared maths: it needs the file it is appended to.
|
||||
continue;
|
||||
} else if name == "oit_world.wgsl" {
|
||||
let base = include_str!("terrain.wgsl").to_owned() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else if name == "oit_features.wgsl" {
|
||||
let base = super::entity_shader() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else if name == "oit_border.wgsl" {
|
||||
let base = include_str!("border.wgsl").to_owned() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else if name == "oit_clouds.wgsl" {
|
||||
let base = super::entity_shader() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else {
|
||||
sources.push((path, text));
|
||||
}
|
||||
}
|
||||
for (path, text) in sources {
|
||||
// entity.wgsl comes after the overlay's constants (entity_shader).
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.or_else(|_| {
|
||||
@@ -6419,6 +6720,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn texture_filtering_samplers() {
|
||||
use super::{sampler_anisotropy, sprite_sampler_descriptor};
|
||||
// None is the sampler terrain always used.
|
||||
let none = sprite_sampler_descriptor(0, 1);
|
||||
assert_eq!(none.mag_filter, wgpu::FilterMode::Nearest);
|
||||
assert_eq!(none.min_filter, wgpu::FilterMode::Nearest);
|
||||
assert_eq!(none.address_mode_u, wgpu::AddressMode::Repeat);
|
||||
assert_eq!(none.anisotropy_clamp, 1);
|
||||
// LevelRenderer.chunkLayerSampler: linear both ways, clamped.
|
||||
for (mode, aniso) in [(1, 1), (2, 8)] {
|
||||
let d = sprite_sampler_descriptor(mode, aniso);
|
||||
assert_eq!(d.mag_filter, wgpu::FilterMode::Linear);
|
||||
assert_eq!(d.min_filter, wgpu::FilterMode::Linear);
|
||||
assert_eq!(d.mipmap_filter, wgpu::MipmapFilterMode::Linear);
|
||||
assert_eq!(d.address_mode_v, wgpu::AddressMode::ClampToEdge);
|
||||
assert_eq!(d.anisotropy_clamp, aniso);
|
||||
}
|
||||
assert_eq!(
|
||||
[1, 2, 3].map(sampler_anisotropy),
|
||||
[2, 4, 8],
|
||||
"Options.maxAnisotropyValue is 1 << bit"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn shader_globals_match_the_rust_layout() {
|
||||
// terrain.wgsl and entity.wgsl bind the same buffer: same fields, same order.
|
||||
let fields = |source: &str| -> Vec<String> {
|
||||
|
||||
@@ -491,10 +491,6 @@ impl Cull {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn depth_test(&self) -> DepthTest {
|
||||
self.depth_test
|
||||
}
|
||||
|
||||
/// In the world pass after phase 1 (`Early`): draw the tested boxes
|
||||
/// against its depth. Leaves the pass's pipeline and group 0 changed.
|
||||
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
|
||||
@@ -9,7 +9,17 @@ use super::Vertex;
|
||||
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
|
||||
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
|
||||
/// and translucent item quads (32, drawn without depth writes).
|
||||
const MATERIALS: usize = 9;
|
||||
///
|
||||
/// With improved transparency on, quads tagged `OIT_ENTITY` are a tenth
|
||||
/// 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.
|
||||
const MATERIALS: usize = 10;
|
||||
/// 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`,
|
||||
/// `breezeWind`). Only the improved transparency path reads it.
|
||||
pub const OIT_ENTITY: u32 = 1 << 24;
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
|
||||
@@ -30,7 +40,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order).
|
||||
fn material(layer: u32) -> usize {
|
||||
fn material(layer: u32, oit: bool) -> usize {
|
||||
if oit && layer & OIT_ENTITY != 0 {
|
||||
return 9;
|
||||
}
|
||||
match layer {
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
@@ -60,6 +73,7 @@ impl EntityBatch {
|
||||
additive: &[Vertex],
|
||||
hand: &[Vertex],
|
||||
see_through: &[Vertex],
|
||||
oit: bool,
|
||||
) -> [u32; MATERIALS] {
|
||||
let quads = entities.as_chunks::<4>().0;
|
||||
for runs in &mut self.runs {
|
||||
@@ -68,10 +82,10 @@ impl EntityBatch {
|
||||
let mut counts = [0usize; MATERIALS];
|
||||
let mut start = 0;
|
||||
while start < quads.len() {
|
||||
let kind = material(quads[start][0].layer);
|
||||
let kind = material(quads[start][0].layer, oit);
|
||||
let end = quads[start + 1..]
|
||||
.iter()
|
||||
.position(|quad| material(quad[0].layer) != kind)
|
||||
.position(|quad| material(quad[0].layer, oit) != kind)
|
||||
.map_or(quads.len(), |n| start + 1 + n);
|
||||
self.runs[kind].push((start, end - start));
|
||||
counts[kind] += end - start;
|
||||
@@ -156,13 +170,46 @@ mod tests {
|
||||
hand: &[Vertex],
|
||||
) {
|
||||
let (expected, counts) = baseline(entities, additive, hand);
|
||||
assert_eq!(batch.update(entities, additive, hand, &[]), counts);
|
||||
assert_eq!(batch.update(entities, additive, hand, &[], false), counts);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&batch.combined),
|
||||
bytemuck::cast_slice::<_, u8>(&expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oit_entity_quads_are_a_last_material_only_when_on() {
|
||||
let quad = |layer: u32, x: f32| {
|
||||
(0..4).map(move |_| Vertex {
|
||||
pos: [x, 0.0, 0.0],
|
||||
layer,
|
||||
..geometry(1, false)[0]
|
||||
})
|
||||
};
|
||||
// Plain, tagged, plain, shadow, tagged with a hurt overlay.
|
||||
let entities: Vec<Vertex> = [
|
||||
(0, 0.0),
|
||||
(OIT_ENTITY | 3 << 16, 1.0),
|
||||
(1, 2.0),
|
||||
(SHADOW_OVERLAY, 3.0),
|
||||
(OIT_ENTITY | 1, 4.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
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]);
|
||||
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]);
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_material_order_tails_and_reuse() {
|
||||
let mut batch = EntityBatch::default();
|
||||
@@ -227,7 +274,7 @@ mod tests {
|
||||
equivalent(&mut batch, &entities, &additive, &hand);
|
||||
for _ in 0..50 {
|
||||
black_box(baseline(black_box(&entities), &additive, &hand));
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
|
||||
black_box(&batch.combined);
|
||||
}
|
||||
let mut before = Vec::new();
|
||||
@@ -240,7 +287,7 @@ mod tests {
|
||||
before.push(start.elapsed().as_nanos() / 100);
|
||||
let start = Instant::now();
|
||||
for _ in 0..100 {
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
|
||||
black_box(&batch.combined);
|
||||
}
|
||||
after.push(start.elapsed().as_nanos() / 100);
|
||||
|
||||
@@ -0,0 +1,968 @@
|
||||
//! Improved transparency: vanilla 26.3's order-independent transparency
|
||||
//! (`LevelRenderer.executeOit`). The translucent layer is drawn three times
|
||||
//! against the opaque world's depth, without writing it, into targets the
|
||||
//! next stage reads (`oit.wgsl` says what each holds), then a full-screen pass
|
||||
//! composites the sum. With the option off none of this exists and the world
|
||||
//! draws as before.
|
||||
|
||||
use super::oit_features::Features;
|
||||
use glam::Mat4;
|
||||
|
||||
/// The depth bounds' clear: `LevelRenderer.DEPTH_BOUNDS_CLEAR_COLOR`.
|
||||
const BOUNDS_CLEAR: wgpu::Color = wgpu::Color {
|
||||
r: -(f32::MAX as f64),
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 0.0,
|
||||
};
|
||||
const BOUNDS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
const COEFFICIENT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
/// The shader's bind group: `terrain.wgsl` holds groups 0 and 1.
|
||||
const GROUP: u32 = 2;
|
||||
|
||||
/// `OitStage`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
DepthBounds,
|
||||
Transmittance,
|
||||
Accumulate,
|
||||
}
|
||||
|
||||
/// What the device must support: max blending on 32-bit float targets.
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
adapter.features() & wgpu::Features::FLOAT32_BLENDABLE
|
||||
}
|
||||
|
||||
pub fn supported(device: &wgpu::Device) -> bool {
|
||||
device
|
||||
.features()
|
||||
.contains(wgpu::Features::FLOAT32_BLENDABLE)
|
||||
}
|
||||
|
||||
/// What a stage's pipelines write: `OIT_*_SNIPPET`'s colour targets.
|
||||
pub(super) fn stage_targets(stage: Stage) -> Vec<Option<wgpu::ColorTargetState>> {
|
||||
let blend = |operation| wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation,
|
||||
};
|
||||
let target = |format, operation| {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: blend(operation),
|
||||
alpha: blend(operation),
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})
|
||||
};
|
||||
let add = wgpu::BlendOperation::Add;
|
||||
match stage {
|
||||
Stage::DepthBounds => vec![target(BOUNDS_FORMAT, wgpu::BlendOperation::Max)],
|
||||
Stage::Transmittance => vec![
|
||||
target(COEFFICIENT_FORMAT, add),
|
||||
target(COEFFICIENT_FORMAT, add),
|
||||
],
|
||||
Stage::Accumulate => vec![target(COEFFICIENT_FORMAT, add)],
|
||||
}
|
||||
}
|
||||
|
||||
/// `ProjMat[2][2]` and `ProjMat[3][2]`: a device depth `d` is `y / (d + x)`
|
||||
/// blocks along the view axis (`oit_linear_depth`).
|
||||
fn projection_terms(projection: &Mat4) -> [f32; 2] {
|
||||
[projection.z_axis.z, projection.w_axis.z]
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
bounds: wgpu::Texture,
|
||||
culled: wgpu::Texture,
|
||||
views: Views,
|
||||
/// The uniform alone, with the culled bounds, with those and the
|
||||
/// coefficients, for the cull with the unculled bounds, and the composite.
|
||||
alone: wgpu::BindGroup,
|
||||
sampled: wgpu::BindGroup,
|
||||
accumulate: wgpu::BindGroup,
|
||||
cull: wgpu::BindGroup,
|
||||
composite: wgpu::BindGroup,
|
||||
/// The depth the terrain's stages test against while a water mask is
|
||||
/// drawn (a copy of the world's, plus the masks), and the clouds' own.
|
||||
terrain_depth: Option<Depth>,
|
||||
cloud_depth: Option<Depth>,
|
||||
}
|
||||
|
||||
struct Depth {
|
||||
texture: wgpu::Texture,
|
||||
view: wgpu::TextureView,
|
||||
}
|
||||
|
||||
impl Depth {
|
||||
fn new(device: &wgpu::Device, label: &str, size: [u32; 2]) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent(size),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extent(size: [u32; 2]) -> wgpu::Extent3d {
|
||||
wgpu::Extent3d {
|
||||
width: size[0].max(1),
|
||||
height: size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the translucent layer's other drawers add to a frame: each draws with
|
||||
/// a pipeline per stage where it has one.
|
||||
#[derive(Default)]
|
||||
pub struct Extras<'a> {
|
||||
/// `executeOitWaterMask`: the boats' water patches, depth only.
|
||||
pub water_mask: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>)>,
|
||||
/// `CloudRenderer.renderOit`: draws the clouds with the pipeline given.
|
||||
pub clouds: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline)>,
|
||||
/// "Features, World Border, Weather": draws what the entity, weather and
|
||||
/// border shaders put after the opaque world, with `Oit::features`'s
|
||||
/// pipelines of the stage.
|
||||
pub features: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, Stage)>,
|
||||
}
|
||||
|
||||
struct Views {
|
||||
bounds: wgpu::TextureView,
|
||||
culled: wgpu::TextureView,
|
||||
coefficients: [wgpu::TextureView; 2],
|
||||
accumulated: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct Oit {
|
||||
uniform: wgpu::Buffer,
|
||||
/// Layouts of the groups at `GROUP`: the uniform; with the bounds; with the
|
||||
/// coefficients; and with the accumulation too.
|
||||
layouts: [wgpu::BindGroupLayout; 4],
|
||||
/// One per `Stage`.
|
||||
terrain: [wgpu::RenderPipeline; 3],
|
||||
/// The terrain's flat section draws (`Flat`), where the device has them.
|
||||
flat: Option<[wgpu::RenderPipeline; 3]>,
|
||||
features: Features,
|
||||
clouds: [wgpu::RenderPipeline; 3],
|
||||
cull: wgpu::RenderPipeline,
|
||||
composite: wgpu::RenderPipeline,
|
||||
targets: Option<Targets>,
|
||||
}
|
||||
|
||||
impl Oit {
|
||||
/// `terrain`: the terrain bind group layout that `vs_terrain` reads,
|
||||
/// `flat` the flat draws' second group, `features` the entity, weather
|
||||
/// and world border layouts,
|
||||
/// `clouds` the cloud pipeline's two, `sprites` the terrain's override
|
||||
/// constants, `format` the world target's.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
terrain: &wgpu::BindGroupLayout,
|
||||
flat: Option<&wgpu::BindGroupLayout>,
|
||||
features: [&wgpu::BindGroupLayout; 3],
|
||||
clouds: [&wgpu::BindGroupLayout; 2],
|
||||
sprites: [f64; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let instance = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::TERRAIN_ATTRIBUTES,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance,
|
||||
}),
|
||||
];
|
||||
let buffers = &buffers[..];
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../terrain.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Improved transparency"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let entry = |binding: u32, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty,
|
||||
count: None,
|
||||
};
|
||||
let texture = |binding| {
|
||||
entry(
|
||||
binding,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
)
|
||||
};
|
||||
let params = entry(
|
||||
0,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
);
|
||||
let layout = |label, entries: &[wgpu::BindGroupLayoutEntry]| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries,
|
||||
})
|
||||
};
|
||||
let layouts = [
|
||||
layout("Improved transparency", &[params]),
|
||||
layout("Improved transparency bounds", &[params, texture(1)]),
|
||||
layout(
|
||||
"Improved transparency coefficients",
|
||||
&[params, texture(1), texture(2), texture(3)],
|
||||
),
|
||||
layout(
|
||||
"Improved transparency composite",
|
||||
&[params, texture(1), texture(2), texture(3), texture(4)],
|
||||
),
|
||||
];
|
||||
let pipeline_layout = |first: Option<&wgpu::BindGroupLayout>, group: usize| {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency"),
|
||||
bind_group_layouts: &[first, None, Some(&layouts[group])],
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
|
||||
let add = wgpu::BlendOperation::Add;
|
||||
let depth = |write| {
|
||||
Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
})
|
||||
};
|
||||
let pipeline = |label,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
targets: &[Option<wgpu::ColorTargetState>],
|
||||
depth_stencil| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: vertex.starts_with("vs_terrain").then_some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let terrain_pipeline = |label, fragment, stage| {
|
||||
pipeline(
|
||||
label,
|
||||
&pipeline_layout(Some(terrain), stage as usize),
|
||||
"vs_terrain",
|
||||
fragment,
|
||||
buffers,
|
||||
&stage_targets(stage),
|
||||
depth(false),
|
||||
)
|
||||
};
|
||||
let terrain_pipelines = [
|
||||
terrain_pipeline("OIT depth bounds", "fs_oit_bounds", Stage::DepthBounds),
|
||||
terrain_pipeline(
|
||||
"OIT transmittance",
|
||||
"fs_oit_transmittance",
|
||||
Stage::Transmittance,
|
||||
),
|
||||
terrain_pipeline("OIT accumulate", "fs_oit_accumulate", Stage::Accumulate),
|
||||
];
|
||||
// The same fragments over `vs_terrain_flat`: one vertex buffer, and
|
||||
// the draw table at group 1.
|
||||
let flat_pipelines = flat.map(|draw| {
|
||||
let flat_buffers = &buffers[..1];
|
||||
[
|
||||
(
|
||||
"OIT depth bounds (flat)",
|
||||
"fs_oit_bounds",
|
||||
Stage::DepthBounds,
|
||||
),
|
||||
(
|
||||
"OIT transmittance (flat)",
|
||||
"fs_oit_transmittance",
|
||||
Stage::Transmittance,
|
||||
),
|
||||
(
|
||||
"OIT accumulate (flat)",
|
||||
"fs_oit_accumulate",
|
||||
Stage::Accumulate,
|
||||
),
|
||||
]
|
||||
.map(|(label, fragment, stage)| {
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency flat terrain"),
|
||||
bind_group_layouts: &[
|
||||
Some(terrain),
|
||||
Some(draw),
|
||||
Some(&layouts[stage as usize]),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
pipeline(
|
||||
label,
|
||||
&layout,
|
||||
"vs_terrain_flat",
|
||||
fragment,
|
||||
flat_buffers,
|
||||
&stage_targets(stage),
|
||||
depth(false),
|
||||
)
|
||||
})
|
||||
});
|
||||
let screen = |label, fragment, group, targets: &[_], depth_stencil| {
|
||||
pipeline(
|
||||
label,
|
||||
&pipeline_layout(None, group),
|
||||
"vs_sky",
|
||||
fragment,
|
||||
&[],
|
||||
targets,
|
||||
depth_stencil,
|
||||
)
|
||||
};
|
||||
let replace = |format| {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})
|
||||
};
|
||||
let cull = screen(
|
||||
"OIT cull depth bounds",
|
||||
"fs_oit_cull",
|
||||
1,
|
||||
&[replace(BOUNDS_FORMAT)],
|
||||
depth(true),
|
||||
);
|
||||
// `RenderPipelines.OIT_COMPOSITE`: premultiplied alpha over the world,
|
||||
// the nearest see-through depth written.
|
||||
let premultiplied = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: add,
|
||||
};
|
||||
let composite = screen(
|
||||
"OIT composite",
|
||||
"fs_oit_composite",
|
||||
3,
|
||||
&[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: premultiplied,
|
||||
alpha: premultiplied,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
);
|
||||
// The clouds: entity.wgsl's vertex shader and vertex format, with the
|
||||
// stages' fragments.
|
||||
let cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency clouds"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_clouds.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let cloud_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let cloud_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &cloud_attributes,
|
||||
})];
|
||||
let cloud_pipeline = |label, fragment, stage: Stage| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency clouds"),
|
||||
bind_group_layouts: &[
|
||||
Some(clouds[0]),
|
||||
Some(clouds[1]),
|
||||
Some(&layouts[stage as usize]),
|
||||
],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &cloud_shader,
|
||||
entry_point: Some("vs_cloud"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &cloud_buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
// `OIT_CLOUDS`: only the bounds stage writes the cloud depth,
|
||||
// so the later stages keep the nearest face of each cloud.
|
||||
depth_stencil: depth(stage == Stage::DepthBounds),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &cloud_shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: Default::default(),
|
||||
targets: &stage_targets(stage),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let cloud_pipelines = [
|
||||
cloud_pipeline(
|
||||
"OIT cloud depth bounds",
|
||||
"fs_oit_cloud_bounds",
|
||||
Stage::DepthBounds,
|
||||
),
|
||||
cloud_pipeline(
|
||||
"OIT cloud transmittance",
|
||||
"fs_oit_cloud_transmittance",
|
||||
Stage::Transmittance,
|
||||
),
|
||||
cloud_pipeline(
|
||||
"OIT cloud accumulate",
|
||||
"fs_oit_cloud_accumulate",
|
||||
Stage::Accumulate,
|
||||
),
|
||||
];
|
||||
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
|
||||
Self {
|
||||
uniform,
|
||||
layouts,
|
||||
terrain: terrain_pipelines,
|
||||
flat: flat_pipelines,
|
||||
features,
|
||||
clouds: cloud_pipelines,
|
||||
cull,
|
||||
composite,
|
||||
targets: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipeline the terrain's translucent layer draws with in `stage`.
|
||||
pub fn terrain(&self, stage: Stage) -> &wgpu::RenderPipeline {
|
||||
&self.terrain[stage as usize]
|
||||
}
|
||||
|
||||
/// The pipeline the terrain's translucent layer draws with in `stage`
|
||||
/// as flat section draws, where they run.
|
||||
pub fn flat(&self, stage: Stage) -> Option<&wgpu::RenderPipeline> {
|
||||
self.flat.as_ref().map(|f| &f[stage as usize])
|
||||
}
|
||||
|
||||
pub fn features(&self) -> &Features {
|
||||
&self.features
|
||||
}
|
||||
|
||||
/// Sizes the targets to the world's and writes this frame's projection.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
projection: &Mat4,
|
||||
wants: [bool; 2],
|
||||
) {
|
||||
queue.write_buffer(
|
||||
&self.uniform,
|
||||
0,
|
||||
bytemuck::cast_slice(&[projection_terms(projection), [0.0; 2]].concat()),
|
||||
);
|
||||
let [mask, clouds] = wants;
|
||||
if let Some(t) = self.targets.as_mut().filter(|t| t.size == size) {
|
||||
if mask && t.terrain_depth.is_none() {
|
||||
t.terrain_depth = Some(Depth::new(device, "OIT terrain depth", size));
|
||||
}
|
||||
if clouds && t.cloud_depth.is_none() {
|
||||
t.cloud_depth = Some(Depth::new(device, "OIT cloud depth", size));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let make = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent(size),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let attachment =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let bounds = make(
|
||||
"OIT depth bounds",
|
||||
BOUNDS_FORMAT,
|
||||
attachment | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let culled = make(
|
||||
"OIT culled depth bounds",
|
||||
BOUNDS_FORMAT,
|
||||
attachment | wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let coefficients =
|
||||
[0, 1].map(|_| make("OIT transmittance", COEFFICIENT_FORMAT, attachment));
|
||||
let accumulated = make("OIT accumulate", COEFFICIENT_FORMAT, attachment);
|
||||
let views = Views {
|
||||
bounds: bounds.create_view(&Default::default()),
|
||||
culled: culled.create_view(&Default::default()),
|
||||
coefficients: coefficients.map(|t| t.create_view(&Default::default())),
|
||||
accumulated: accumulated.create_view(&Default::default()),
|
||||
};
|
||||
let group = |layout: &wgpu::BindGroupLayout, views: &[&wgpu::TextureView]| {
|
||||
let mut entries = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform.as_entire_binding(),
|
||||
}];
|
||||
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
|
||||
binding: i as u32 + 1,
|
||||
resource: wgpu::BindingResource::TextureView(v),
|
||||
}));
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Improved transparency"),
|
||||
layout,
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
let [c0, c1] = &views.coefficients;
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
alone: group(&self.layouts[0], &[]),
|
||||
sampled: group(&self.layouts[1], &[&views.culled]),
|
||||
accumulate: group(&self.layouts[2], &[&views.culled, c0, c1]),
|
||||
cull: group(&self.layouts[1], &[&views.bounds]),
|
||||
composite: group(
|
||||
&self.layouts[3],
|
||||
&[&views.bounds, c0, c1, &views.accumulated],
|
||||
),
|
||||
terrain_depth: mask.then(|| Depth::new(device, "OIT terrain depth", size)),
|
||||
cloud_depth: clouds.then(|| Depth::new(device, "OIT cloud depth", size)),
|
||||
bounds,
|
||||
culled,
|
||||
views,
|
||||
});
|
||||
}
|
||||
|
||||
/// `LevelRenderer.executeOit`: `draw` records the terrain's translucent
|
||||
/// layer into each stage's pass with that stage's pipeline, `extras` the
|
||||
/// water masks and clouds, then the composite goes over `color`. `depth`
|
||||
/// is the opaque world's: the stages only read it; the cull and the
|
||||
/// composite write it.
|
||||
pub fn run(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
|
||||
extras: &Extras<'_>,
|
||||
) {
|
||||
let Some(t) = &self.targets else { return };
|
||||
let attachment = |view, load| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})
|
||||
};
|
||||
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
|
||||
let load = wgpu::LoadOp::Load;
|
||||
let read_only = |view| {
|
||||
Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
depth_ops: None,
|
||||
stencil_ops: None,
|
||||
})
|
||||
};
|
||||
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
|
||||
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
|
||||
if let Some((mask, _)) = water_mask {
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
}
|
||||
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
|
||||
let [c0, c1] = &t.views.coefficients;
|
||||
let culled = &t.views.culled;
|
||||
for (stage, label, group, first, then) in [
|
||||
(
|
||||
Stage::DepthBounds,
|
||||
"OIT depth bounds",
|
||||
&t.alone,
|
||||
vec![attachment(
|
||||
&t.views.bounds,
|
||||
wgpu::LoadOp::Clear(BOUNDS_CLEAR),
|
||||
)],
|
||||
// The clouds' bounds join the culled ones.
|
||||
vec![attachment(culled, load)],
|
||||
),
|
||||
(
|
||||
Stage::Transmittance,
|
||||
"OIT transmittance",
|
||||
&t.sampled,
|
||||
vec![attachment(c0, clear), attachment(c1, clear)],
|
||||
vec![attachment(c0, load), attachment(c1, load)],
|
||||
),
|
||||
(
|
||||
Stage::Accumulate,
|
||||
"OIT accumulate",
|
||||
&t.accumulate,
|
||||
vec![attachment(&t.views.accumulated, clear)],
|
||||
vec![attachment(&t.views.accumulated, load)],
|
||||
),
|
||||
] {
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &first,
|
||||
depth_stencil_attachment: read_only(terrain_depth),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
draw(&mut pass, stage);
|
||||
}
|
||||
if let Some(features) = extras.features {
|
||||
// Over the world's own depth, even where a water mask patched
|
||||
// the terrain's: the masks only hide water.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &[attachment(
|
||||
match stage {
|
||||
Stage::DepthBounds => &t.views.bounds,
|
||||
Stage::Transmittance => c0,
|
||||
Stage::Accumulate => &t.views.accumulated,
|
||||
},
|
||||
load,
|
||||
)]
|
||||
.into_iter()
|
||||
.chain((stage == Stage::Transmittance).then(|| attachment(c1, load)))
|
||||
.collect::<Vec<_>>(),
|
||||
depth_stencil_attachment: read_only(depth),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
features(&mut pass, stage);
|
||||
}
|
||||
if stage == Stage::DepthBounds {
|
||||
self.execute_depth_bounds_cull(encoder, t, depth);
|
||||
}
|
||||
if let Some((clouds, cloud_depth)) = cloud_depth {
|
||||
self.render_clouds(
|
||||
encoder,
|
||||
t,
|
||||
stage,
|
||||
depth,
|
||||
(label, group, &then),
|
||||
(clouds, cloud_depth),
|
||||
);
|
||||
}
|
||||
if stage == Stage::DepthBounds
|
||||
&& let Some((mask, _)) = water_mask
|
||||
{
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
}
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT composite"),
|
||||
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.composite);
|
||||
pass.set_bind_group(GROUP, &t.composite, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
|
||||
/// the world's depth (after the cull) and draws into it, so the later
|
||||
/// stages, which only read it, keep each pixel's nearest cloud face.
|
||||
fn render_clouds(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
stage: Stage,
|
||||
depth: &wgpu::TextureView,
|
||||
(label, group, targets): (
|
||||
&str,
|
||||
&wgpu::BindGroup,
|
||||
&[Option<wgpu::RenderPassColorAttachment<'_>>],
|
||||
),
|
||||
(draw, cloud_depth): (
|
||||
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
|
||||
&Depth,
|
||||
),
|
||||
) {
|
||||
if stage == Stage::DepthBounds {
|
||||
encoder.copy_texture_to_texture(
|
||||
depth.texture().as_image_copy(),
|
||||
cloud_depth.texture.as_image_copy(),
|
||||
extent(t.size),
|
||||
);
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: targets,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &cloud_depth.view,
|
||||
depth_ops: (stage == Stage::DepthBounds).then_some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
draw(&mut pass, &self.clouds[stage as usize]);
|
||||
}
|
||||
|
||||
/// `LevelRenderer.executeOitWaterMask`: the terrain's depth is the world's
|
||||
/// again, with the boats' water patches on top.
|
||||
fn execute_water_mask(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
|
||||
) {
|
||||
let Some(terrain) = &t.terrain_depth else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
depth.texture().as_image_copy(),
|
||||
terrain.texture.as_image_copy(),
|
||||
extent(t.size),
|
||||
);
|
||||
// The mask pipeline writes depth only but has a colour target.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT water mask"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: color,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &terrain.view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
mask(&mut pass);
|
||||
}
|
||||
|
||||
/// `LevelRenderer.executeDepthBoundsCull`: the bounds are copied, then where
|
||||
/// the nearest fully opaque see-through fragment is in front of the world,
|
||||
/// the far bound stops there and its depth replaces the world's.
|
||||
fn execute_depth_bounds_cull(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
depth: &wgpu::TextureView,
|
||||
) {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.bounds.as_image_copy(),
|
||||
t.culled.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: t.size[0].max(1),
|
||||
height: t.size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT cull depth bounds"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &t.views.culled,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.cull);
|
||||
pass.set_bind_group(GROUP, &t.cull, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn validate(source: &str, entries: &[&str]) {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
for entry in entries {
|
||||
assert!(
|
||||
module.entry_points.iter().any(|e| e.name == *entry),
|
||||
"{entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_terrain_shader_validates() {
|
||||
validate(
|
||||
&(include_str!("../terrain.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl")),
|
||||
&[
|
||||
"fs_oit_bounds",
|
||||
"fs_oit_transmittance",
|
||||
"fs_oit_accumulate",
|
||||
"fs_oit_cull",
|
||||
"fs_oit_composite",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cloud_shader_validates() {
|
||||
validate(
|
||||
&(super::super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_clouds.wgsl")),
|
||||
&[
|
||||
"fs_oit_cloud_bounds",
|
||||
"fs_oit_cloud_transmittance",
|
||||
"fs_oit_cloud_accumulate",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_feature_shaders_validate() {
|
||||
let stages = |name: &str| {
|
||||
["bounds", "transmittance", "accumulate"].map(|s| format!("fs_oit_{name}_{s}"))
|
||||
};
|
||||
let entity = ["entity", "shadow", "weather", "swirl"]
|
||||
.map(stages)
|
||||
.concat();
|
||||
validate(
|
||||
&(super::super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_features.wgsl")),
|
||||
&entity.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let border = stages("border");
|
||||
validate(
|
||||
&(include_str!("../border.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_border.wgsl")),
|
||||
&border.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_depth_gives_the_distance_along_the_view_axis() {
|
||||
// The world's reversed-Z projection: near plane at depth 1.
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let [x, y] = projection_terms(&projection);
|
||||
for distance in [0.1_f32, 1.0, 7.5, 64.0, 500.0] {
|
||||
let clip = projection * glam::Vec4::new(0.0, 0.0, -distance, 1.0);
|
||||
let device = clip.z / clip.w;
|
||||
let linear = y / (device + x);
|
||||
assert!(
|
||||
(linear - distance).abs() < distance * 2e-3,
|
||||
"{distance}: {linear}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Improved transparency for what is drawn after the opaque world besides the
|
||||
//! terrain and the clouds: `LevelRenderer.executeOit`'s "Features, World
|
||||
//! Border, Weather" pass. Each kind keeps its classic vertex shader, vertex
|
||||
//! format and depth state (minus the depth writes, as every `OIT_*` pipeline),
|
||||
//! with the three stages' fragments (`oit_features.wgsl`, `oit_border.wgsl`).
|
||||
|
||||
use super::oit::{Stage, stage_targets};
|
||||
|
||||
/// One pipeline per stage.
|
||||
type Set = [wgpu::RenderPipeline; 3];
|
||||
|
||||
pub struct Features {
|
||||
/// `OIT_ENTITY`/`OIT_ITEM`: translucent items and particles.
|
||||
pub entity: Set,
|
||||
/// `OIT_TEXT_POLYGON_OFFSET`.
|
||||
pub text: Set,
|
||||
/// `OIT_CRUMBLING`.
|
||||
pub crumbling: Set,
|
||||
/// `OIT_ENTITY_SHADOW`.
|
||||
pub shadow: Set,
|
||||
/// `OIT_ENERGY_SWIRL`.
|
||||
pub swirl: Set,
|
||||
/// `OIT_LIGHTNING`: the retained bolts.
|
||||
pub lightning: Set,
|
||||
/// `OIT_DRAGON_RAYS`: the additive geometry after the bolts.
|
||||
pub additive: Set,
|
||||
/// `OIT_WEATHER`.
|
||||
pub weather: Set,
|
||||
/// `OIT_WORLD_BORDER`.
|
||||
pub border: Set,
|
||||
}
|
||||
|
||||
/// What a kind differs in.
|
||||
struct Kind<'a> {
|
||||
label: &'a str,
|
||||
/// The entry points' common prefix: `fs_oit_<name>_<stage>`.
|
||||
name: &'a str,
|
||||
vertex: &'a str,
|
||||
cull: Option<wgpu::Face>,
|
||||
bias: wgpu::DepthBiasState,
|
||||
}
|
||||
|
||||
const PLAIN: wgpu::DepthBiasState = wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
|
||||
impl Features {
|
||||
/// `layouts`: `Oit`'s stage groups (at group 2); `entity`, `weather` and
|
||||
/// `border` the bind group layouts of the classic pipelines.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
layouts: &[wgpu::BindGroupLayout; 4],
|
||||
entity: &wgpu::BindGroupLayout,
|
||||
weather: &wgpu::BindGroupLayout,
|
||||
border: &wgpu::BindGroupLayout,
|
||||
) -> Self {
|
||||
let entity_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency features"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_features.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let border_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency world border"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../border.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_border.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let weather_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
|
||||
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
|
||||
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
|
||||
let entity_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &entity_attributes,
|
||||
})];
|
||||
let weather_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<crate::weather::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &weather_attributes,
|
||||
})];
|
||||
let border_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<crate::border_render::BorderVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &border_attributes,
|
||||
})];
|
||||
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let lightning_buffers = [
|
||||
entity_buffers[0].clone(),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<[f32; 3]>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &lightning_attributes,
|
||||
}),
|
||||
];
|
||||
let set = |module: &wgpu::ShaderModule,
|
||||
groups: [Option<&wgpu::BindGroupLayout>; 2],
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
kind: Kind<'_>| {
|
||||
[Stage::DepthBounds, Stage::Transmittance, Stage::Accumulate].map(|stage| {
|
||||
let suffix = ["bounds", "transmittance", "accumulate"][stage as usize];
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency features"),
|
||||
bind_group_layouts: &[groups[0], groups[1], Some(&layouts[stage as usize])],
|
||||
..Default::default()
|
||||
});
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&format!("OIT {} {suffix}", kind.label)),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module,
|
||||
entry_point: Some(kind.vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: kind.cull,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: kind.bias,
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some(&format!("fs_oit_{}_{suffix}", kind.name)),
|
||||
compilation_options: Default::default(),
|
||||
targets: &stage_targets(stage),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
};
|
||||
let entities = [Some(entity), None];
|
||||
let kind = |label, name, cull, bias| Kind {
|
||||
label,
|
||||
name,
|
||||
vertex: "vs_entity",
|
||||
cull,
|
||||
bias,
|
||||
};
|
||||
// The classic pipelines' biases: text and cracks (10, 1), shadows
|
||||
// (128 ulps and a slope, over the block tops they lie on).
|
||||
let offset = wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
let shadow_bias = wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
};
|
||||
Self {
|
||||
entity: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("entities", "entity", None, PLAIN),
|
||||
),
|
||||
text: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("text", "entity", None, offset),
|
||||
),
|
||||
crumbling: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("breaking cracks", "entity", Some(wgpu::Face::Back), offset),
|
||||
),
|
||||
shadow: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("entity shadows", "shadow", None, shadow_bias),
|
||||
),
|
||||
swirl: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("energy swirls", "swirl", None, PLAIN),
|
||||
),
|
||||
lightning: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&lightning_buffers,
|
||||
Kind {
|
||||
label: "lightning",
|
||||
name: "lightning",
|
||||
vertex: "vs_lightning",
|
||||
cull: None,
|
||||
bias: PLAIN,
|
||||
},
|
||||
),
|
||||
additive: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("additive entities", "lightning", None, PLAIN),
|
||||
),
|
||||
weather: set(
|
||||
&entity_module,
|
||||
[Some(entity), Some(weather)],
|
||||
&weather_buffers,
|
||||
Kind {
|
||||
label: "weather",
|
||||
name: "weather",
|
||||
vertex: "vs_weather",
|
||||
cull: None,
|
||||
bias: PLAIN,
|
||||
},
|
||||
),
|
||||
// `OIT_WORLD_BORDER`: a polygon offset of 3 towards the camera.
|
||||
border: set(
|
||||
&border_module,
|
||||
[Some(border), None],
|
||||
&border_buffers,
|
||||
Kind {
|
||||
label: "world border",
|
||||
name: "border",
|
||||
vertex: "vs_border",
|
||||
cull: None,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 3,
|
||||
slope_scale: 3.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,7 +349,10 @@ impl PackEntities {
|
||||
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
|
||||
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
|
||||
for (i, c) in entity_color(run[0].layer).iter().enumerate() {
|
||||
for (i, c) in entity_color(run[0].layer & !super::entity_batch::OIT_ENTITY)
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
self.uniforms.extend_from_slice(&bytes);
|
||||
@@ -408,9 +411,12 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
|
||||
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
|
||||
/// shader draws in its own way; entity.wgsl).
|
||||
fn plain(run: &[Vertex]) -> bool {
|
||||
run.iter()
|
||||
.step_by(4)
|
||||
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
|
||||
run.iter().step_by(4).all(|v| {
|
||||
!matches!(
|
||||
v.layer & !super::entity_batch::OIT_ENTITY,
|
||||
2 | 4 | 6 | 9 | 10 | 11 | 12 | 34
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The programs of entity runs, in the order they draw.
|
||||
|
||||
@@ -124,6 +124,26 @@ impl Weather {
|
||||
uploads.buffer(encoder, &self.vertices, 0, bytemuck::cast_slice(vertices));
|
||||
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&time));
|
||||
}
|
||||
/// The classic pipeline, for its layouts.
|
||||
pub fn pipeline(&self) -> &wgpu::RenderPipeline {
|
||||
&self.pipeline
|
||||
}
|
||||
/// `draw` with another pipeline of the same layouts and vertex format.
|
||||
pub fn draw_with(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
entities: &wgpu::BindGroup,
|
||||
) {
|
||||
if self.count == 0 {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, entities, &[]);
|
||||
pass.set_bind_group(1, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
|
||||
}
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
|
||||
if self.count == 0 {
|
||||
return;
|
||||
|
||||
@@ -344,7 +344,7 @@ impl Post {
|
||||
"Scaled world depth",
|
||||
size,
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
sampled,
|
||||
sampled | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
|
||||
@@ -311,6 +311,8 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The name the options screen waits on to bind `Graphics::hdr_key`.
|
||||
pub const HDR_KEY: &str = "horizon.hdrSettings";
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
@@ -391,6 +393,8 @@ pub struct Graphics {
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
|
||||
pub hdr_key: String,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
@@ -429,6 +433,7 @@ impl Default for Graphics {
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
hdr_key: "key.keyboard.unknown".into(),
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
@@ -507,6 +512,7 @@ impl Graphics {
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
@@ -524,7 +530,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -556,11 +562,20 @@ impl Graphics {
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
self.hdr_key,
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
)
|
||||
}
|
||||
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
HDR_KEY => self.hdr_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
@@ -691,6 +706,7 @@ mod tests {
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
hdr_key: "key.keyboard.h".into(),
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! lay out rows and handle input; a menu theme replaces this module.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
@@ -156,10 +157,11 @@ impl Style {
|
||||
cost: Cost,
|
||||
text: &str,
|
||||
) {
|
||||
let tag = Text::literal(cost.label());
|
||||
let tag = Text::literal(lang::get(cost.label()));
|
||||
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
|
||||
let indent = atlas.font.width_text(&tag) + 4.0;
|
||||
let lines = wrap(atlas, text, self.text_width(batch.width), indent);
|
||||
let text = lang::get(text);
|
||||
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
|
||||
for (i, line) in lines.iter().take(2).enumerate() {
|
||||
let lx = if i == 0 { x + indent } else { x };
|
||||
batch.text_styled(
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::{
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
settings::{Opt, Settings},
|
||||
text::Text,
|
||||
ui,
|
||||
@@ -43,6 +44,8 @@ pub enum H {
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrBlackLevel,
|
||||
/// Binds `Graphics::hdr_key`.
|
||||
HdrKey,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
@@ -153,6 +156,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
h(
|
||||
H::HdrKey,
|
||||
None,
|
||||
"A key that opens this screen from the game. Not bound at first.",
|
||||
),
|
||||
],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
@@ -354,11 +362,14 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
fn on_off(on: bool) -> &'static str {
|
||||
if on { "On" } else { "Off" }
|
||||
fn on_off(on: bool) -> String {
|
||||
lang::get(if on { "On" } else { "Off" })
|
||||
}
|
||||
/// A control's caption and its value, both through the language table: Horizon's captions are
|
||||
/// English sentences, and some of the values ("Off", "12 chunks") are words too.
|
||||
fn labelled(caption: &str, value: impl std::fmt::Display) -> Text {
|
||||
Text::literal(format!("{caption}: {value}"))
|
||||
let value = value.to_string();
|
||||
Text::literal(format!("{}: {}", lang::get(caption), lang::get(&value)))
|
||||
}
|
||||
|
||||
impl H {
|
||||
@@ -396,19 +407,21 @@ impl H {
|
||||
H::Preset => labelled("Preset", g.preset.label()),
|
||||
H::LodDistance => match g.lod_distance {
|
||||
0 => labelled("LOD Distance", "Off"),
|
||||
n => labelled("LOD Distance", format!("{n} chunks")),
|
||||
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", format!("{n} per frame")),
|
||||
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
|
||||
},
|
||||
H::UploadBudget => match g.upload_budget_kib {
|
||||
0 => labelled("Upload Budget", "Unlimited"),
|
||||
k if k >= 1024 => labelled("Upload Budget", format!("{} MiB/frame", k / 1024)),
|
||||
k => labelled("Upload Budget", format!("{k} KiB/frame")),
|
||||
k if k >= 1024 => {
|
||||
labelled("Upload Budget", lang::tr("%s MiB/frame", &[&(k / 1024)]))
|
||||
}
|
||||
k => labelled("Upload Budget", lang::tr("%s KiB/frame", &[&k])),
|
||||
},
|
||||
H::Sky => labelled("Sky", on_off(g.sky)),
|
||||
H::Fog => labelled("Fog", on_off(g.fog)),
|
||||
@@ -423,7 +436,10 @@ impl H {
|
||||
},
|
||||
),
|
||||
H::Workers => match g.workers {
|
||||
0 => labelled("Worker Threads", format!("Auto ({})", g.worker_threads())),
|
||||
0 => labelled(
|
||||
"Worker Threads",
|
||||
lang::tr("Auto (%s)", &[&g.worker_threads()]),
|
||||
),
|
||||
n => labelled("Worker Threads", n),
|
||||
},
|
||||
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
|
||||
@@ -437,23 +453,32 @@ impl H {
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal("HDR Settings..."),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
format!("Auto ({} nits)", g.paper_white_nits()),
|
||||
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
|
||||
),
|
||||
n => labelled("Paper White", format!("{n} nits")),
|
||||
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
|
||||
n => labelled("Peak Brightness", format!("{n} nits")),
|
||||
0 => labelled(
|
||||
"Peak Brightness",
|
||||
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
|
||||
),
|
||||
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
|
||||
lang::tr(
|
||||
"%s nits",
|
||||
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
|
||||
),
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::HdrKey => {
|
||||
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
@@ -618,7 +643,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
|
||||
"Full Detail Distance",
|
||||
format!("{} chunks", s.render_distance),
|
||||
lang::tr("%s chunks", &[&s.render_distance]),
|
||||
),
|
||||
Item::Vanilla(o) => o.label(s),
|
||||
Item::Horizon(h) => h.label(s),
|
||||
@@ -686,6 +711,8 @@ pub enum Action {
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
/// Wait for a key or mouse button to bind to this name.
|
||||
BindKey(&'static str),
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -708,6 +735,8 @@ pub struct GraphicsUi {
|
||||
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<Item>,
|
||||
/// The key binding the options screen is waiting on (`Action::BindKey`).
|
||||
pub binding: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl GraphicsUi {
|
||||
@@ -818,7 +847,7 @@ impl GraphicsUi {
|
||||
let x = STYLE.row_x(w);
|
||||
for (y, name) in headers {
|
||||
if shown(y, STYLE.header_height) {
|
||||
STYLE.header(batch, atlas, &Text::literal(name), x, y);
|
||||
STYLE.header(batch, atlas, &Text::literal(lang::get(name)), x, y);
|
||||
}
|
||||
}
|
||||
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
|
||||
@@ -840,11 +869,17 @@ impl GraphicsUi {
|
||||
if !shown(wd.y, 20.0) {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let mut label = item.label(s);
|
||||
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
|
||||
// KeyBindsScreen: "> key <" while it waits.
|
||||
label = Text::literal("Shortcut: > ")
|
||||
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
|
||||
.append(" <");
|
||||
}
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
if hover && let Some(why) = blocked {
|
||||
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
|
||||
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
@@ -885,7 +920,7 @@ impl GraphicsUi {
|
||||
batch,
|
||||
atlas,
|
||||
(wd.x, wd.y, wd.w),
|
||||
&Text::literal(label),
|
||||
&Text::literal(lang::get(label)),
|
||||
hover,
|
||||
true,
|
||||
);
|
||||
@@ -907,7 +942,7 @@ impl GraphicsUi {
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
Level::Hdr => "Graphics: HDR",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
STYLE.title(batch, atlas, &Text::literal(lang::get(title)));
|
||||
if let Some(lines) = tooltip {
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
@@ -965,6 +1000,12 @@ impl GraphicsUi {
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrKey)) => {
|
||||
vec![
|
||||
Action::ButtonClick,
|
||||
Action::BindKey(crate::graphics::HDR_KEY),
|
||||
]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
|
||||
@@ -62,7 +62,9 @@ pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod post_chain;
|
||||
pub mod post_glsl;
|
||||
pub mod potent_sulfur;
|
||||
pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
|
||||
@@ -130,6 +130,12 @@ impl Mesher {
|
||||
let tint = match name.strip_prefix("minecraft:").unwrap_or(name) {
|
||||
"spruce_leaves" => Tint::Constant(0x61_9961),
|
||||
"birch_leaves" => Tint::Constant(0x80_A755),
|
||||
// Cherry (and other pre-coloured) leaves: the texture is
|
||||
// already pink; biome foliage tint turns a grove yellow (#4688).
|
||||
"cherry_leaves"
|
||||
| "azalea_leaves"
|
||||
| "flowering_azalea_leaves"
|
||||
| "pale_oak_leaves" => Tint::None,
|
||||
"leaf_litter" => Tint::Dry,
|
||||
_ if leaves => Tint::Foliage,
|
||||
_ => Tint::Grass,
|
||||
|
||||
@@ -139,8 +139,12 @@ mod vertex_tests {
|
||||
assert_eq!(hdr_tier("red_candle", 3), LOW);
|
||||
assert_eq!(hdr_tier("glow_lichen", 7), 0);
|
||||
assert_eq!(hdr_tier("stone", 0), 0);
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), LOW);
|
||||
assert_eq!(hdr_tier("respawn_anchor", 3), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW);
|
||||
assert_eq!(hdr_tier("sea_pickle", 15), 0);
|
||||
// Unlisted ones by their light level.
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
|
||||
}
|
||||
}
|
||||
@@ -702,14 +706,25 @@ pub fn hdr_tier(name: &str, level: u8) -> u8 {
|
||||
match name {
|
||||
_ if level == 0 => 0,
|
||||
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" | "beacon" | "end_rod" | "furnace"
|
||||
| "redstone_lamp" => BRIGHT,
|
||||
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
|
||||
| "sea_lantern" => INCANDESCENT,
|
||||
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
|
||||
// Glowing at every charge, not only the brighter ones.
|
||||
"respawn_anchor" => INCANDESCENT,
|
||||
"redstone_torch"
|
||||
| "redstone_wall_torch"
|
||||
| "magma_block"
|
||||
| "ochre_froglight"
|
||||
| "verdant_froglight"
|
||||
| "pearlescent_froglight"
|
||||
| "shroomlight" => LOW,
|
||||
// Its flame is a particle.
|
||||
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
|
||||
// One tier at every oxidation stage, waxed or not.
|
||||
_ if name.ends_with("copper_bulb") => LOW,
|
||||
// A faint glow in the dark, as in SDR.
|
||||
"glow_lichen" => 0,
|
||||
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame" => 0,
|
||||
_ if level >= 10 => INCANDESCENT,
|
||||
_ => LOW,
|
||||
}
|
||||
|
||||
@@ -579,6 +579,9 @@ impl MultiLineEdit {
|
||||
if show_cursor && !insert_cursor && self.within(cursor_y + shift, cursor_y + shift + LINE) {
|
||||
batch.text(atlas, "_", cursor_x, cursor_y, cursor_color, true);
|
||||
}
|
||||
if self.focused {
|
||||
crate::preedit::report_caret(cursor_x, cursor_y, 10.0);
|
||||
}
|
||||
if self.has_selection() {
|
||||
let (sel_begin, sel_end) = self.selected();
|
||||
let draw_x = inner_left;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Improved transparency (the options screen's order-independent transparency),
|
||||
// appended to terrain.wgsl. Vanilla 26.3 draws its translucent layer three
|
||||
// times: once to find each pixel's nearest and furthest see-through depth (the
|
||||
// depth bounds), once to add each fragment's absorbance into a small wavelet
|
||||
// transform over that range (the transmittance), and once to weight every
|
||||
// fragment's colour by what the transform says lies in front of it (the
|
||||
// accumulation). A last full-screen pass puts the sum over the opaque world.
|
||||
//
|
||||
// Eight coefficients in two RGBA16F targets: [0] is the mean absorbance, [1]
|
||||
// the widest wavelet, [2..4] the next level, [4..8] the finest, one per depth
|
||||
// bin pair.
|
||||
struct OitParams {
|
||||
// x, y: the projection's [2][2] and [3][2], which turn a device depth into
|
||||
// a distance along the view axis: y / (depth + x).
|
||||
projection: vec4<f32>,
|
||||
}
|
||||
@group(2) @binding(0) var<uniform> oit: OitParams;
|
||||
@group(2) @binding(1) var oit_bounds: texture_2d<f32>;
|
||||
@group(2) @binding(2) var oit_coeff0: texture_2d<f32>;
|
||||
@group(2) @binding(3) var oit_coeff1: texture_2d<f32>;
|
||||
@group(2) @binding(4) var oit_accumulated: texture_2d<f32>;
|
||||
|
||||
const OIT_FULLY_OPAQUE_ALPHA: f32 = 0.99;
|
||||
const OIT_FULLY_OPAQUE_TRANSMITTANCE: f32 = 0.02;
|
||||
const OIT_BINS: i32 = 8;
|
||||
const OIT_LOW_PRECISION_BINS: f32 = 2.0;
|
||||
const OIT_HIGH_PRECISION_RANGE: f32 = 10.0;
|
||||
const OIT_ALPHA_CUTOUT: f32 = 0.1;
|
||||
|
||||
fn oit_linear_depth(device_depth: f32) -> f32 {
|
||||
return oit.projection.y / (device_depth + oit.projection.x);
|
||||
}
|
||||
fn oit_absorbance(transmittance: f32) -> f32 {
|
||||
return clamp(-log(max(transmittance, 0.0001)), 0.0, 4.0);
|
||||
}
|
||||
fn oit_transmittance(absorbance: f32) -> f32 {
|
||||
return clamp(exp(-absorbance), 0.0001, 1.0);
|
||||
}
|
||||
|
||||
// Where a fragment sits in its pixel's depth range, 0 to 1. The last
|
||||
// OIT_LOW_PRECISION_BINS bins stretch over whatever is past the first ten
|
||||
// blocks, so a thin pane near the eye keeps its resolution in a deep range.
|
||||
fn oit_normalize_depth(device_depth: f32, pixel: vec2<f32>) -> f32 {
|
||||
let bounds = textureLoad(oit_bounds, vec2<i32>(pixel), 0);
|
||||
let nearest = -bounds.r;
|
||||
let furthest = bounds.g;
|
||||
let linear = oit_linear_depth(device_depth);
|
||||
let range = max(furthest - nearest, 0.0001);
|
||||
let within = clamp(linear - nearest, 0.0, range);
|
||||
let fine_share = (f32(OIT_BINS) - OIT_LOW_PRECISION_BINS) / f32(OIT_BINS);
|
||||
let fine_range = fine_share * range;
|
||||
if fine_range <= OIT_HIGH_PRECISION_RANGE {
|
||||
return within / range;
|
||||
}
|
||||
if within <= OIT_HIGH_PRECISION_RANGE {
|
||||
return within / OIT_HIGH_PRECISION_RANGE * fine_share;
|
||||
}
|
||||
return fine_share + (within - OIT_HIGH_PRECISION_RANGE)
|
||||
/ (range - OIT_HIGH_PRECISION_RANGE) * (1.0 - fine_share);
|
||||
}
|
||||
|
||||
struct OitCoefficients {
|
||||
@location(0) first: vec4<f32>,
|
||||
@location(1) second: vec4<f32>,
|
||||
}
|
||||
// One fragment's contribution to the transform (summed by additive blending).
|
||||
fn oit_add_transmittance(transmittance: f32, frag_depth: f32, pixel: vec2<f32>) -> OitCoefficients {
|
||||
var c: array<f32, 8>;
|
||||
let absorbance = oit_absorbance(transmittance);
|
||||
let original = oit_normalize_depth(frag_depth, pixel);
|
||||
let depth = original * f32(OIT_BINS - 1) / f32(OIT_BINS);
|
||||
c[0] = absorbance * (1.0 - depth);
|
||||
// The wavelet tree's leaves start at index 8.
|
||||
var tree = i32(floor(original * f32(OIT_BINS - 1))) + OIT_BINS;
|
||||
for (var level = 0; level <= 2; level++) {
|
||||
let power = 2 - level;
|
||||
let parent = tree >> 1u;
|
||||
let index_at_parent = f32(parent & ((1 << u32(power)) - 1));
|
||||
let right = tree & 1;
|
||||
let side = 1 - (right << 1u);
|
||||
let width = exp2(-f32(power));
|
||||
let height = exp2(f32(power) * 0.5);
|
||||
let offset = depth - width * index_at_parent;
|
||||
c[parent] = (f32(right) * width + f32(side) * offset) * height * absorbance;
|
||||
tree = parent;
|
||||
}
|
||||
return OitCoefficients(vec4(c[0], c[1], c[2], c[3]), vec4(c[4], c[5], c[6], c[7]));
|
||||
}
|
||||
|
||||
// The absorbance in front of `original` (a normalized depth), without the
|
||||
// fragment's own contribution `current` that the transform already holds.
|
||||
fn oit_sample_absorbance(coefficients: array<f32, 8>, original: f32, current: f32) -> f32 {
|
||||
var c = coefficients;
|
||||
var average = c[0];
|
||||
if average == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let in_bins = original * f32(OIT_BINS - 1);
|
||||
let depth = in_bins / f32(OIT_BINS);
|
||||
average -= current * (1.0 - depth);
|
||||
// B: the bin the fragment is in; A: the one before it.
|
||||
var tree_b = clamp(i32(floor(in_bins)), 0, OIT_BINS - 1);
|
||||
let sample_a_too = tree_b >= 1;
|
||||
var tree_a = select(tree_b, tree_b - 1, sample_a_too);
|
||||
tree_b += OIT_BINS;
|
||||
tree_a += OIT_BINS;
|
||||
var sample_b = average;
|
||||
var sample_a = select(0.0, average, sample_a_too);
|
||||
for (var level = 0; level <= 2; level++) {
|
||||
let power = 2 - level;
|
||||
let width = exp2(-f32(power));
|
||||
let height = exp2(f32(power) * 0.5);
|
||||
let parent_b = tree_b >> 1u;
|
||||
let right_b = tree_b & 1;
|
||||
let side_b = 1 - (right_b << 1u);
|
||||
let index_at_parent = f32(parent_b & ((1 << u32(power)) - 1));
|
||||
let offset = depth - width * index_at_parent;
|
||||
let own = (f32(right_b) * width + f32(side_b) * offset) * height * current;
|
||||
let difference_b = c[parent_b] - own;
|
||||
sample_b -= height * difference_b * f32(side_b);
|
||||
tree_b = parent_b;
|
||||
if sample_a_too {
|
||||
let parent_a = tree_a >> 1u;
|
||||
let difference_a = select(c[parent_a], difference_b, parent_a == parent_b);
|
||||
let side_a = 1 - ((tree_a & 1) << 1u);
|
||||
sample_a -= height * difference_a * f32(side_a);
|
||||
tree_a = parent_a;
|
||||
}
|
||||
}
|
||||
let blend = select(fract(in_bins), 1.0, in_bins >= f32(OIT_BINS));
|
||||
return mix(sample_a, sample_b, blend);
|
||||
}
|
||||
fn oit_sample_transmittance(pixel: vec2<i32>, depth: f32, current: f32) -> f32 {
|
||||
let a = textureLoad(oit_coeff0, pixel, 0);
|
||||
let b = textureLoad(oit_coeff1, pixel, 0);
|
||||
let c = array<f32, 8>(a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w);
|
||||
return oit_transmittance(oit_sample_absorbance(c, depth, oit_absorbance(current)));
|
||||
}
|
||||
|
||||
// The three stages' ends for a pipeline whose fragment colour is computed as
|
||||
// it is without improved transparency (vanilla's `executeAlphaOnlyPhase` and
|
||||
// `sampleColorForAccumulation`). `color` is the classic result, fogged:
|
||||
// weighting a fogged colour equals fogging the weighted one, since the fog
|
||||
// is a mix and vanilla premultiplies the fog colour by the same alpha.
|
||||
fn oit_bounds_fragment(alpha: f32, clip: vec4<f32>) -> vec4<f32> {
|
||||
if alpha < 0.01 { discard; }
|
||||
let linear = oit_linear_depth(clip.z);
|
||||
let opaque = select(0.0, clip.z, alpha > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, clip.z, opaque);
|
||||
}
|
||||
fn oit_transmittance_fragment(alpha: f32, clip: vec4<f32>) -> OitCoefficients {
|
||||
return oit_add_transmittance(1.0 - alpha, clip.z, clip.xy);
|
||||
}
|
||||
fn oit_accumulate_fragment(color: vec4<f32>, clip: vec4<f32>) -> vec4<f32> {
|
||||
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
|
||||
oit_normalize_depth(clip.z, clip.xy), 1.0 - color.a);
|
||||
return vec4(color.rgb * color.a, color.a) * seen;
|
||||
}
|
||||
// `OIT_ADDITIVE`: no coverage and nothing in the transmittance. The colour
|
||||
// is not fogged yet: the fog colour is premultiplied by the alpha, which is
|
||||
// zero, so the fog fades it out instead of tinting it.
|
||||
fn oit_accumulate_additive(color: vec4<f32>, fog: f32, clip: vec4<f32>) -> vec4<f32> {
|
||||
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
|
||||
oit_normalize_depth(clip.z, clip.xy), 1.0);
|
||||
return vec4(color.rgb * color.a * (1.0 - fog), 0.0) * seen;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Improved transparency's stages for the world border (`OIT_WORLD_BORDER`:
|
||||
// `world_border.fsh` with `OIT_ADDITIVE`), appended to border.wgsl after
|
||||
// oit.wgsl.
|
||||
@fragment fn fs_oit_border_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(border_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_border_transmittance(in: Out) -> OitCoefficients {
|
||||
discard;
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_border_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_additive(border_color(in), 0.0, in.clip);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Improved transparency's cloud stages (`clouds.fsh` under OIT_*), appended to
|
||||
// entity.wgsl after oit.wgsl. The clouds' vertex shader and draw are the
|
||||
// classic ones; only the fragment colour goes through the three stages.
|
||||
|
||||
// The cloud's colour; it fades out towards the cloud fog end everywhere but in
|
||||
// the depth bounds stage, which sees the unfaded alpha.
|
||||
fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front);
|
||||
let faded = 1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y);
|
||||
return vec4(color.rgb, color.a * select(1.0, faded, fade));
|
||||
}
|
||||
@fragment fn fs_oit_cloud_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let color = oit_cloud_color(in, front, false);
|
||||
if color.a < 0.01 { discard; }
|
||||
let linear = oit_linear_depth(in.clip.z);
|
||||
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, in.clip.z, opaque);
|
||||
}
|
||||
@fragment fn fs_oit_cloud_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
|
||||
let color = oit_cloud_color(in, front, true);
|
||||
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
|
||||
}
|
||||
@fragment fn fs_oit_cloud_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let color = oit_cloud_color(in, front, true);
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
return vec4(color.rgb * color.a, color.a) * seen;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Improved transparency's stages for what the entity shader draws after the
|
||||
// opaque world (`LevelRenderer.executeOit`'s "Features, World Border,
|
||||
// Weather" pass), appended to entity.wgsl after oit.wgsl. The colour of each
|
||||
// is the classic fragment's, so the shaders cannot drift apart.
|
||||
|
||||
// Items, particles, text, breaking cracks (`RenderPipelines.OIT_ENTITY` and
|
||||
// the others on entity.fsh).
|
||||
@fragment fn fs_oit_entity_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(entity_color(in, front).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_entity_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
|
||||
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(entity_color(in, front), in.clip);
|
||||
}
|
||||
// `OIT_ENTITY_SHADOW`.
|
||||
@fragment fn fs_oit_shadow_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(shadow_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_shadow_transmittance(in: Out) -> OitCoefficients {
|
||||
return oit_transmittance_fragment(shadow_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_shadow_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(shadow_color(in), in.clip);
|
||||
}
|
||||
// `OIT_WEATHER` (the particle shader).
|
||||
@fragment fn fs_oit_weather_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(weather_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_weather_transmittance(in: Out) -> OitCoefficients {
|
||||
return oit_transmittance_fragment(weather_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_weather_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(weather_color(in), in.clip);
|
||||
}
|
||||
// `OIT_ENERGY_SWIRL`: entity.fsh with `OIT_ADDITIVE`, which caps the alpha.
|
||||
@fragment fn fs_oit_swirl_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(min(0.99, energy_swirl_base(in).a), in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_swirl_transmittance(in: Out) -> OitCoefficients {
|
||||
discard;
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_swirl_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = energy_swirl_base(in);
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), energy_swirl_fog(in), in.clip);
|
||||
}
|
||||
// `OIT_LIGHTNING` and `OIT_DRAGON_RAYS`: additive (`OIT_ADDITIVE`), the
|
||||
// classic fragment's colour (fs_celestial) with the alpha capped.
|
||||
@fragment fn fs_oit_lightning_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(min(0.99, celestial_color(in).a), in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_lightning_transmittance(in: Out) -> OitCoefficients {
|
||||
discard;
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Improved transparency's terrain stages and its screen passes (oit.wgsl
|
||||
// has the shared maths), appended to terrain.wgsl after oit.wgsl.
|
||||
// The terrain's translucent layer, once per stage.
|
||||
fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < OIT_ALPHA_CUTOUT { discard; }
|
||||
return color;
|
||||
}
|
||||
// Stage 1: (-nearest, furthest, nearest device depth, nearest opaque device
|
||||
// depth), combined by max blending.
|
||||
@fragment fn fs_oit_bounds(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = oit_terrain_color(in);
|
||||
let linear = oit_linear_depth(in.clip.z);
|
||||
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, in.clip.z, opaque);
|
||||
}
|
||||
// Stage 2.
|
||||
@fragment fn fs_oit_transmittance(in: TerrainOut) -> OitCoefficients {
|
||||
let color = oit_terrain_color(in);
|
||||
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
|
||||
}
|
||||
// Stage 3: premultiplied colour, weighted by the transmittance in front.
|
||||
@fragment fn fs_oit_accumulate(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = oit_terrain_color(in);
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
let weighted = vec4(color.rgb * color.a, color.a) * seen;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
// The fog colour is premultiplied by the same weight.
|
||||
let rgb = mix(weighted.rgb, globals.fog_color.rgb * weighted.a, f * globals.fog_color.a);
|
||||
return vec4(rgb, weighted.a);
|
||||
}
|
||||
|
||||
struct OitFragment {
|
||||
@location(0) color: vec4<f32>,
|
||||
@builtin(frag_depth) depth: f32,
|
||||
}
|
||||
// After the bounds stage: a fully opaque fragment hides what is behind it, so
|
||||
// the far bound stops there and its depth goes into the depth buffer.
|
||||
@fragment fn fs_oit_cull(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
let b = textureLoad(oit_bounds, vec2<i32>(pos.xy), 0);
|
||||
let opaque = oit_linear_depth(b.a);
|
||||
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), b.a);
|
||||
}
|
||||
// The sum over the world, renormalized to the coverage the transform gives.
|
||||
@fragment fn fs_oit_composite(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
let p = vec2<i32>(pos.xy);
|
||||
let accumulated = textureLoad(oit_accumulated, p, 0);
|
||||
let seen = oit_sample_transmittance(p, 100000.0, 1.0);
|
||||
let coverage = 1.0 - select(seen, 0.0, seen < OIT_FULLY_OPAQUE_TRANSMITTANCE);
|
||||
// Additive surfaces add light but no coverage.
|
||||
if coverage < 0.00001 && dot(accumulated.rgb, vec3(1.0)) < 0.00001 { discard; }
|
||||
let scale = select(1.0, coverage / accumulated.a, accumulated.a > 0.00001);
|
||||
return OitFragment(vec4(accumulated.rgb * scale, coverage), textureLoad(oit_bounds, p, 0).b);
|
||||
}
|
||||
@@ -141,9 +141,6 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the terrain sampler has no RGSS or anisotropic filtering yet.
|
||||
| Opt::TextureFiltering
|
||||
| Opt::MaxAnisotropy
|
||||
// Saved, but the mouse doesn't read it yet.
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
@@ -1352,6 +1349,9 @@ pub struct OptionsUi {
|
||||
pub resource_error: Option<String>,
|
||||
/// When the pack screen's search box took the focus (its cursor blink).
|
||||
pack_focus: Instant,
|
||||
/// The selection carried over from a pack screen made again over a changed pack list
|
||||
/// (`update_pack_infos`) differs from the settings'.
|
||||
pack_reselected: bool,
|
||||
pub in_world: bool,
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
@@ -1387,6 +1387,7 @@ impl Default for OptionsUi {
|
||||
data_pack_stamp: (Vec::new(), Instant::now()),
|
||||
resource_error: None,
|
||||
pack_focus: Instant::now(),
|
||||
pack_reselected: false,
|
||||
in_world: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
@@ -1418,6 +1419,10 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
if !opt.available() {
|
||||
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
|
||||
}
|
||||
// VideoSettingsScreen.tick: Max Anisotropy only works with Anisotropic filtering.
|
||||
if opt == Opt::MaxAnisotropy && s.file.int("textureFiltering") != 2 {
|
||||
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
|
||||
}
|
||||
match opt.kind(s) {
|
||||
Kind::Slider(v) => Widget {
|
||||
x,
|
||||
@@ -1472,6 +1477,23 @@ impl OptionsUi {
|
||||
self.settings_search.focused
|
||||
&& matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds)
|
||||
}
|
||||
/// Whether the resource pack screen is open (not Create World's data packs on the same
|
||||
/// page).
|
||||
pub fn resource_packs_open(&self) -> bool {
|
||||
self.page() == Page::Packs && self.data_pack_dir.is_none()
|
||||
}
|
||||
/// The packs in `resourcepacks/` changed while the pack screen is open
|
||||
/// (`PackSelectionScreen`'s directory watcher): the screen is made again over
|
||||
/// `infos`, keeping the selection made on it so far.
|
||||
pub fn update_pack_infos(&mut self, infos: Vec<crate::pack_screen::PackInfo>) {
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
&& p.changed
|
||||
{
|
||||
self.pack_selection = p.commit();
|
||||
self.pack_reselected = true;
|
||||
}
|
||||
self.pack_infos = infos;
|
||||
}
|
||||
pub fn page(&self) -> Page {
|
||||
*self.stack.last().unwrap_or(&Page::Main)
|
||||
}
|
||||
@@ -2588,6 +2610,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
self.graphics.binding = self.selected_key;
|
||||
self.graphics
|
||||
.draw(level, s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
@@ -2847,6 +2870,15 @@ impl OptionsUi {
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
use crate::graphics_ui::Action;
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
if self.selected_key.is_some() && !drag {
|
||||
let button = if right {
|
||||
winit::event::MouseButton::Right
|
||||
} else {
|
||||
winit::event::MouseButton::Left
|
||||
};
|
||||
return self.bind_mouse(s, button);
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
for action in self
|
||||
.graphics
|
||||
@@ -2858,6 +2890,7 @@ impl OptionsUi {
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::BindKey(name) => self.selected_key = Some(name),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -3226,7 +3259,7 @@ impl OptionsUi {
|
||||
} else {
|
||||
"resourcePack.title"
|
||||
};
|
||||
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
|
||||
let mut screen = crate::pack_screen::PackScreen::new(
|
||||
&m,
|
||||
crate::text::Text::translate(title, &[]),
|
||||
w,
|
||||
@@ -3234,7 +3267,9 @@ impl OptionsUi {
|
||||
self.in_world,
|
||||
self.pack_infos.clone(),
|
||||
&self.pack_selection,
|
||||
));
|
||||
);
|
||||
screen.changed = std::mem::take(&mut self.pack_reselected);
|
||||
self.pack_screen = Some(screen);
|
||||
}
|
||||
}
|
||||
self.pack_screen.as_mut().expect("made above")
|
||||
@@ -3348,7 +3383,7 @@ impl OptionsUi {
|
||||
keys.insert(key.clone(), value);
|
||||
return vec![Event::Changed];
|
||||
}
|
||||
if !s.extras.set_key(name, value.clone()) {
|
||||
if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) {
|
||||
s.keys.set(name, &value);
|
||||
}
|
||||
vec![Event::Changed]
|
||||
@@ -4024,9 +4059,21 @@ mod tests {
|
||||
assert_eq!(s.file.int("maxAnisotropyBit"), bit, "{preset}");
|
||||
assert_eq!(s.file.int("textureFiltering"), filtering, "{preset}");
|
||||
}
|
||||
// Nothing samples the terrain atlas with them yet, so the buttons stay greyed out.
|
||||
assert!(!Opt::TextureFiltering.available());
|
||||
assert!(!Opt::MaxAnisotropy.available());
|
||||
assert!(Opt::TextureFiltering.available());
|
||||
assert!(Opt::MaxAnisotropy.available());
|
||||
// The anisotropy slider only works with Anisotropic filtering (VideoSettingsScreen.tick).
|
||||
let grey = |s: &Settings| {
|
||||
matches!(
|
||||
option_widget(s, Opt::MaxAnisotropy, 0.0, 0.0, 150.0).kind,
|
||||
WKind::Button { active: false, .. }
|
||||
)
|
||||
};
|
||||
s.file
|
||||
.set("textureFiltering", crate::options_file::V::Int(1));
|
||||
assert!(grey(&s));
|
||||
s.file
|
||||
.set("textureFiltering", crate::options_file::V::Int(2));
|
||||
assert!(!grey(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4928,7 +4975,7 @@ mod tests {
|
||||
MouseButton::Back,
|
||||
MouseButton::Forward,
|
||||
] {
|
||||
for name in [SORT_KEY, "key.jump"] {
|
||||
for name in [SORT_KEY, crate::graphics::HDR_KEY, "key.jump"] {
|
||||
let mut ui = OptionsUi::default();
|
||||
let mut s = Settings::default();
|
||||
ui.selected_key = Some(name);
|
||||
@@ -4937,6 +4984,7 @@ mod tests {
|
||||
let value = keybinds::mouse_name(button);
|
||||
match name {
|
||||
SORT_KEY => assert_eq!(s.extras.sort_key, value),
|
||||
crate::graphics::HDR_KEY => assert_eq!(s.horizon.hdr_key, value),
|
||||
_ => assert_eq!(s.keys.get(name), value),
|
||||
}
|
||||
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
|
||||
|
||||
@@ -414,6 +414,13 @@ fn overlays_of(meta: &serde_json::Value) -> Vec<String> {
|
||||
.flatten()
|
||||
.filter(|e| overlay_applies(e))
|
||||
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
|
||||
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
|
||||
.filter(|d| {
|
||||
stays_inside(d)
|
||||
&& !d.contains('/')
|
||||
&& d.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn filter_of(meta: &serde_json::Value) -> Vec<Filter> {
|
||||
@@ -539,7 +546,7 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
|
||||
let zip = Zip::open(z)?;
|
||||
for root in &roots {
|
||||
let prefix = format!("{root}assets/");
|
||||
for name in zip.names() {
|
||||
for name in zip.names().filter(|name| stays_inside(name)) {
|
||||
if let Some(rest) = name.strip_prefix(&prefix) {
|
||||
out.insert(format!("assets/{rest}"), (None, name.to_owned()));
|
||||
}
|
||||
@@ -549,6 +556,15 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
/// Whether a zip entry's name, joined to the overlay folder, stays in it: `/`-separated parts
|
||||
/// (zip.rs turns `\` into `/`), none empty, `.` or `..`, and no `:` (a drive or a stream on
|
||||
/// Windows). A pack, a server's too, may name entries anything.
|
||||
fn stays_inside(name: &str) -> bool {
|
||||
!name.contains([':', '\0'])
|
||||
&& name
|
||||
.split('/')
|
||||
.all(|part| !part.is_empty() && part != "." && part != "..")
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
// Relative sources are relative to the process, not the overlay directory.
|
||||
@@ -1422,6 +1438,56 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn zip_entries_never_leave_the_overlay() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
|
||||
fs::write(
|
||||
vanilla.join("assets/minecraft/textures/block/stone.png"),
|
||||
b"vanilla",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(base.join("packs")).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(base.join("packs/evil.zip")).unwrap());
|
||||
for (name, data) in [
|
||||
(
|
||||
"pack.mcmeta",
|
||||
&br#"{"pack":{"description":"d","pack_format":97}}"#[..],
|
||||
),
|
||||
("assets/minecraft/textures/block/stone.png", b"pack"),
|
||||
("assets/../../escaped.txt", b"out"),
|
||||
("assets/minecraft/../../../escaped_lang.json", b"{}"),
|
||||
("assets\\..\\..\\escaped_backslash.txt", b"out"),
|
||||
("assets/./minecraft/dot.txt", b"in"),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let packs = discover(&vanilla, &base.join("packs"));
|
||||
assert_eq!(packs.len(), 1);
|
||||
let out = base.join("work/overlay");
|
||||
build_overlay(&vanilla, &packs, &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join("assets/minecraft/textures/block/stone.png")).unwrap(),
|
||||
b"pack"
|
||||
);
|
||||
for escaped in [
|
||||
"escaped.txt",
|
||||
"escaped_lang.json",
|
||||
"escaped_backslash.txt",
|
||||
"work/escaped.txt",
|
||||
"work/escaped_lang.json",
|
||||
"work/escaped_backslash.txt",
|
||||
] {
|
||||
assert!(!base.join(escaped).exists(), "{escaped} was written");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn built_in_and_folder_packs_give_their_pack_png() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-icons-{}", std::process::id()));
|
||||
@@ -1469,6 +1535,15 @@ mod tests {
|
||||
{"min_format":97,"max_format":[97,5],"directory":"also"},
|
||||
{"formats":50,"directory":"no"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["new", "also"]);
|
||||
// Only one plain folder of the pack, as vanilla's DIR_VALIDATOR has it.
|
||||
let meta = v(r#"{"overlays":{"entries":[
|
||||
{"formats":97,"directory":".."},
|
||||
{"formats":97,"directory":"../outside"},
|
||||
{"formats":97,"directory":"/etc"},
|
||||
{"formats":97,"directory":"a/b"},
|
||||
{"formats":97,"directory":"C:"},
|
||||
{"formats":97,"directory":"ok_1.21-x"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["ok_1.21-x"]);
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-ov-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
|
||||
@@ -9,7 +9,9 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Bump when overlay/merge semantics change. Markers are disposable cache data.
|
||||
// Version 4 mirrored every untouched directory file by file on Windows.
|
||||
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
|
||||
const VERSION: u32 = 5;
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
const VERSION: u32 = 6;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
@@ -18,13 +20,16 @@ pub(super) struct Dependencies {
|
||||
failed: bool,
|
||||
#[serde(skip)]
|
||||
tracking: bool,
|
||||
/// The vanilla root (absolute): its files are stamped without reading them.
|
||||
#[serde(skip)]
|
||||
vanilla: PathBuf,
|
||||
}
|
||||
impl Dependencies {
|
||||
fn record(&mut self, path: &Path, directory: bool) {
|
||||
if !self.tracking {
|
||||
return;
|
||||
}
|
||||
match dependency_signature(path, directory) {
|
||||
match dependency_signature(path, directory, &self.vanilla) {
|
||||
Ok(signature) => self.entries.push((path.into(), directory, signature)),
|
||||
Err(_) => self.failed = true,
|
||||
}
|
||||
@@ -38,7 +43,7 @@ impl Dependencies {
|
||||
fn valid(&self) -> bool {
|
||||
!self.failed
|
||||
&& self.entries.iter().all(|(path, directory, signature)| {
|
||||
dependency_signature(path, *directory).is_ok_and(|s| s == *signature)
|
||||
dependency_signature(path, *directory, &self.vanilla).is_ok_and(|s| s == *signature)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -72,13 +77,16 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes.
|
||||
fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> {
|
||||
let meta = fs::metadata(path)?;
|
||||
hash.update(meta.len().to_le_bytes());
|
||||
field(hash, format!("{:?}", meta.modified()?).as_bytes());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// The change time stands in for the bytes here.
|
||||
let _ = read;
|
||||
hash.update(meta.dev().to_le_bytes());
|
||||
hash.update(meta.ino().to_le_bytes());
|
||||
hash.update(meta.ctime().to_le_bytes());
|
||||
@@ -87,8 +95,11 @@ fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Portable metadata lacks a change timestamp. Preserve content-based
|
||||
// validation for materialized files on those platforms.
|
||||
if meta.is_file() {
|
||||
// validation for the packs' materialized files on those platforms;
|
||||
// vanilla's (the launcher's, tens of thousands) keep to the size and
|
||||
// the write and creation times: reading each costs minutes on Windows,
|
||||
// where every open is scanned by the antivirus.
|
||||
if read && meta.is_file() {
|
||||
return content(hash, path);
|
||||
}
|
||||
if let Ok(created) = meta.created() {
|
||||
@@ -104,17 +115,18 @@ fn entries(path: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
entries.sort();
|
||||
Ok(entries)
|
||||
}
|
||||
fn dependency_signature(path: &Path, directory: bool) -> io::Result<String> {
|
||||
fn dependency_signature(path: &Path, directory: bool, vanilla: &Path) -> io::Result<String> {
|
||||
let mut hash = Sha256::new();
|
||||
let read = !(path.starts_with(vanilla) || absolute(path).is_ok_and(|p| p.starts_with(vanilla)));
|
||||
match fs::metadata(path) {
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => field(&mut hash, b"missing"),
|
||||
Err(e) => return Err(e),
|
||||
Ok(meta) if directory && meta.is_dir() => {
|
||||
field(&mut hash, b"directory");
|
||||
stamp(&mut hash, path)?;
|
||||
stamp(&mut hash, path, read)?;
|
||||
}
|
||||
Ok(_) if directory => field(&mut hash, b"not-directory"),
|
||||
Ok(_) => stamp(&mut hash, path)?,
|
||||
Ok(_) => stamp(&mut hash, path, read)?,
|
||||
}
|
||||
Ok(finish(hash))
|
||||
}
|
||||
@@ -190,7 +202,7 @@ fn tree_inner(
|
||||
if pack.is_none() {
|
||||
return content(hash, path);
|
||||
}
|
||||
stamp(hash, path)?;
|
||||
stamp(hash, path, true)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -283,11 +295,14 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
|
||||
let signature = pack_signature(packs);
|
||||
if let Ok(signature) = &signature
|
||||
&& let Ok(bytes) = fs::read(&marker_path)
|
||||
&& let Ok(marker) = serde_json::from_slice::<Marker>(&bytes)
|
||||
&& let Ok(mut marker) = serde_json::from_slice::<Marker>(&bytes)
|
||||
&& marker.version == VERSION
|
||||
&& marker.vanilla == vanilla_key
|
||||
&& marker.packs == *signature
|
||||
&& marker.dependencies.valid()
|
||||
&& {
|
||||
marker.dependencies.vanilla = vanilla_key.clone();
|
||||
marker.dependencies.valid()
|
||||
}
|
||||
&& out.is_dir()
|
||||
{
|
||||
return Ok(());
|
||||
@@ -300,6 +315,7 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
|
||||
}
|
||||
let mut dependencies = Dependencies {
|
||||
tracking: signature.is_ok(),
|
||||
vanilla: vanilla_key.clone(),
|
||||
..Dependencies::default()
|
||||
};
|
||||
build_overlay_uncached(vanilla, packs, out, &mut dependencies)?;
|
||||
|
||||
@@ -1064,8 +1064,9 @@ pub struct ParticleEngine {
|
||||
pub particle_level: u8,
|
||||
/// FireworkParticles.Starter instances.
|
||||
starters: Vec<Starter>,
|
||||
/// Local sounds requested by particles (dripstone drips): (event, position, volume, pitch).
|
||||
pub sounds: Vec<(&'static str, [f64; 3], f32, f32)>,
|
||||
/// Local sounds requested by particles (dripstone drips, firework bursts):
|
||||
/// (event, position, volume, pitch, distanceDelay).
|
||||
pub sounds: Vec<(&'static str, [f64; 3], f32, f32, bool)>,
|
||||
/// How many of each type were made, for `horizon-ctl status`.
|
||||
made: std::collections::BTreeMap<&'static str, u64>,
|
||||
/// The item particles' sprites (`BreakingItemParticle`'s slime ball, snowball
|
||||
@@ -3059,7 +3060,7 @@ impl ParticleEngine {
|
||||
(false, false) => "minecraft:entity.firework_rocket.blast",
|
||||
};
|
||||
let pitch = 0.95 + self.random.next_float() * 0.1;
|
||||
self.sounds.push((sound, s.at, 20.0, pitch));
|
||||
self.sounds.push((sound, s.at, 20.0, pitch, true));
|
||||
}
|
||||
if s.life % 2 == 0 && ((s.life / 2) as usize) < s.explosions.len() {
|
||||
let e = s.explosions[(s.life / 2) as usize].clone();
|
||||
@@ -3110,7 +3111,7 @@ impl ParticleEngine {
|
||||
"minecraft:entity.firework_rocket.twinkle"
|
||||
};
|
||||
let pitch = 0.9 + self.random.next_float() * 0.15;
|
||||
self.sounds.push((sound, s.at, 20.0, pitch));
|
||||
self.sounds.push((sound, s.at, 20.0, pitch, true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -3305,7 +3306,7 @@ impl ParticleEngine {
|
||||
}
|
||||
if let Some(sound) = sound {
|
||||
let volume = p.random.next_float() * 0.7 + 0.3;
|
||||
self.sounds.push((sound, p.position, volume, 1.0));
|
||||
self.sounds.push((sound, p.position, volume, 1.0, false));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,9 @@
|
||||
// The built-in stand-ins for the vanilla post shaders a `post_effect` chain can
|
||||
// name (`core/screenquad`, `post/rotscale`, `post/invert`, `post/blit`,
|
||||
// `post/bits`, `post/color_convolve`, `post/box_blur`, `post/spiderclip`).
|
||||
// Coordinates stay in the chain's own convention (v up from the bottom edge) and
|
||||
// are flipped only where a texture is read.
|
||||
// Targets keep GL's rows (row 0 is the bottom edge): the passes draw with clip y
|
||||
// negated (`vs_quad_gl`, `vs_rotscale`) and read their textures as they are;
|
||||
// `vs_quad` with `fs_blit` is the flip between the frame and a chain's `main`.
|
||||
|
||||
// OutSize, InSize | BlurSize, menu blur radius.
|
||||
@group(0) @binding(0) var<uniform> info: array<vec4<f32>, 2>;
|
||||
@@ -27,10 +28,10 @@ fn fcf(i: u32) -> f32 {
|
||||
return fs_cfg[i / 4u][i % 4u];
|
||||
}
|
||||
fn read_in(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(in_tex, in_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
|
||||
return textureSampleLevel(in_tex, in_smp, uv, 0.0);
|
||||
}
|
||||
fn read_blur(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(blur_tex, blur_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
|
||||
return textureSampleLevel(blur_tex, blur_smp, uv, 0.0);
|
||||
}
|
||||
|
||||
fn corner(i: u32) -> vec2<f32> {
|
||||
@@ -46,10 +47,19 @@ fn corner(i: u32) -> vec2<f32> {
|
||||
return out;
|
||||
}
|
||||
|
||||
@vertex fn vs_quad_gl(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = corner(i);
|
||||
var out: Out;
|
||||
out.clip = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
out.uv = p;
|
||||
out.scaled = p;
|
||||
return out;
|
||||
}
|
||||
|
||||
@vertex fn vs_rotscale(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = corner(i);
|
||||
var out: Out;
|
||||
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.clip = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
out.uv = p;
|
||||
let radians = vcf(4u) * 0.0174532925;
|
||||
let c = cos(radians);
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Vanilla's post-processing GLSL (`#version 330` with `layout(std140)` uniform blocks
|
||||
//! and `uniform sampler2D`s, Vulkan-style `gl_VertexIndex`) turned into naga modules at
|
||||
//! run time, from whichever resource root is active, the way Horizon's shader packs turn
|
||||
//! their GLSL into naga (`horizon-shaders`' translator, which handles the compatibility
|
||||
//! dialect; the post shaders need only the block and sampler binding rewrite below).
|
||||
//!
|
||||
//! `ShaderManager`/`GlslPreprocessor` read `assets/<ns>/shaders/<path>.{vsh,fsh}` and
|
||||
//! resolve `#include <ns:path>` to `assets/<ns>/shaders/include/<path>`.
|
||||
//!
|
||||
//! The rewrite gives every uniform block a binding of bind group 0, shared by both
|
||||
//! stages, and splits each `uniform sampler2D NameSampler;` into a texture and a sampler
|
||||
//! object at the next two bindings, `NameSampler` becoming the combined
|
||||
//! `sampler2D(texture, sampler)` again where it is used. The vertex stage's `main` is
|
||||
//! wrapped so that clip y is negated: targets keep GL's rows (row 0 is the bottom edge),
|
||||
//! so `texCoord` reads the texel it would in vanilla.
|
||||
use naga::ShaderStage;
|
||||
use naga::front::glsl::{Frontend, Options};
|
||||
use naga::valid::{Capabilities, ValidationFlags, Validator};
|
||||
use std::path::Path;
|
||||
|
||||
/// One program, ready for wgpu.
|
||||
pub struct Program {
|
||||
pub vertex: naga::Module,
|
||||
pub fragment: naga::Module,
|
||||
/// Every uniform block either stage declares, with its binding.
|
||||
pub blocks: Vec<(String, u32)>,
|
||||
/// Every sampler (`NameSampler`): the texture's binding; the sampler object is the next.
|
||||
pub samplers: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
fn is_ident(c: u8) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == b'_'
|
||||
}
|
||||
|
||||
/// The source with its comments blanked (same length, so offsets stay valid).
|
||||
fn blank_comments(src: &str) -> String {
|
||||
let b = src.as_bytes();
|
||||
let mut out = b.to_vec();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'/' && b.get(i + 1) == Some(&b'/') {
|
||||
while i < b.len() && b[i] != b'\n' {
|
||||
out[i] = b' ';
|
||||
i += 1;
|
||||
}
|
||||
} else if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
|
||||
while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) {
|
||||
if b[i] != b'\n' {
|
||||
out[i] = b' ';
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
for slot in out.iter_mut().take((i + 2).min(b.len())).skip(i) {
|
||||
*slot = b' ';
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `GlslPreprocessor`'s `#include <ns:path>`.
|
||||
fn read_source(root: &Path, id: &str, ext: &str) -> Result<String, String> {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let file = root
|
||||
.join("assets")
|
||||
.join(ns)
|
||||
.join("shaders")
|
||||
.join(format!("{path}.{ext}"));
|
||||
std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))
|
||||
}
|
||||
|
||||
fn include(root: &Path, src: &str, depth: u32) -> Result<String, String> {
|
||||
if depth > 8 {
|
||||
return Err("#include nested too deep".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
for line in src.lines() {
|
||||
let t = line.trim_start();
|
||||
if let Some(rest) = t.strip_prefix("#include") {
|
||||
let name = rest
|
||||
.trim()
|
||||
.trim_start_matches(['<', '"'])
|
||||
.trim_end_matches(['>', '"'])
|
||||
.trim();
|
||||
let (ns, path) = name.split_once(':').unwrap_or(("minecraft", name));
|
||||
let file = root
|
||||
.join("assets")
|
||||
.join(ns)
|
||||
.join("shaders")
|
||||
.join("include")
|
||||
.join(path);
|
||||
let text = std::fs::read_to_string(&file)
|
||||
.map_err(|e| format!("#include <{name}>: {}: {e}", file.display()))?;
|
||||
out.push_str(&include(root, &text, depth + 1)?);
|
||||
} else if t.starts_with("#version") || t.starts_with("#extension") {
|
||||
// Re-added below, for the translator.
|
||||
out.push('\n');
|
||||
continue;
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every `uniform` declaration of a source: (start of the declaration including a
|
||||
/// `layout(...)` before it, end of the type or of the whole `;`, type, name, is a sampler).
|
||||
fn declarations(src: &str) -> Vec<(usize, usize, String, String, bool)> {
|
||||
let plain = blank_comments(src);
|
||||
let b = plain.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
while let Some(found) = plain[at..].find("uniform") {
|
||||
let start = at + found;
|
||||
at = start + 7;
|
||||
if (start > 0 && is_ident(b[start - 1])) || b.get(start + 7).is_some_and(|c| is_ident(*c)) {
|
||||
continue;
|
||||
}
|
||||
let word = |from: usize| -> Option<(usize, usize)> {
|
||||
let mut s = from;
|
||||
while s < b.len() && b[s].is_ascii_whitespace() {
|
||||
s += 1;
|
||||
}
|
||||
let mut e = s;
|
||||
while e < b.len() && is_ident(b[e]) {
|
||||
e += 1;
|
||||
}
|
||||
(e > s).then_some((s, e))
|
||||
};
|
||||
let Some((ts, te)) = word(start + 7) else {
|
||||
continue;
|
||||
};
|
||||
let ty = plain[ts..te].to_owned();
|
||||
// The `layout(...)` right before `uniform`, if any.
|
||||
let before = plain[..start].trim_end();
|
||||
let decl_start = before
|
||||
.ends_with(')')
|
||||
.then(|| before.rfind("layout"))
|
||||
.flatten()
|
||||
.filter(|l| {
|
||||
before[*l + 6..].trim_start().starts_with('(') && !before[*l..].contains(';')
|
||||
})
|
||||
.unwrap_or(start);
|
||||
if ty.starts_with("sampler") {
|
||||
let Some((ns, ne)) = word(te) else { continue };
|
||||
let end = plain[ne..].find(';').map_or(ne, |p| ne + p + 1);
|
||||
out.push((decl_start, end, ty, plain[ns..ne].to_owned(), true));
|
||||
} else {
|
||||
let mut s = te;
|
||||
while s < b.len() && b[s].is_ascii_whitespace() {
|
||||
s += 1;
|
||||
}
|
||||
if b.get(s) == Some(&b'{') {
|
||||
out.push((decl_start, te, String::new(), ty, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The source with each declaration given its binding from the shared tables.
|
||||
fn rewrite(src: &str, blocks: &[(String, u32)], samplers: &[(String, u32)]) -> String {
|
||||
let mut out = String::new();
|
||||
let mut pos = 0;
|
||||
for (start, end, ty, name, sampler) in declarations(src) {
|
||||
out.push_str(&src[pos..start]);
|
||||
pos = end;
|
||||
if sampler {
|
||||
let tex = samplers.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
|
||||
let kind = if ty == "sampler2D" { "texture2D" } else { &ty };
|
||||
out.push_str(&format!(
|
||||
"layout(set = 0, binding = {tex}) uniform {kind} {name}_hz_tex; \
|
||||
layout(set = 0, binding = {}) uniform sampler {name}_hz_smp;\n\
|
||||
#define {name} sampler2D({name}_hz_tex, {name}_hz_smp)\n",
|
||||
tex + 1
|
||||
));
|
||||
} else {
|
||||
let binding = blocks.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
|
||||
out.push_str(&format!(
|
||||
"layout(std140, set = 0, binding = {binding}) uniform {name}"
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push_str(&src[pos..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Replaces the word `main` of `void main(` with `hz_main`.
|
||||
fn wrap_main(src: &str) -> Result<String, String> {
|
||||
let plain = blank_comments(src);
|
||||
let mut at = 0;
|
||||
while let Some(found) = plain[at..].find("main") {
|
||||
let p = at + found;
|
||||
at = p + 4;
|
||||
let b = plain.as_bytes();
|
||||
if (p > 0 && is_ident(b[p - 1])) || b.get(p + 4).is_some_and(|c| is_ident(*c)) {
|
||||
continue;
|
||||
}
|
||||
if plain[..p].trim_end().ends_with("void") && plain[p + 4..].trim_start().starts_with('(') {
|
||||
let mut out = String::with_capacity(src.len() + 80);
|
||||
out.push_str(&src[..p]);
|
||||
out.push_str("hz_main");
|
||||
out.push_str(&src[p + 4..]);
|
||||
out.push_str("\nvoid main() { hz_main(); gl_Position.y = -gl_Position.y; }\n");
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
Err("no main".into())
|
||||
}
|
||||
|
||||
fn parse(stage: ShaderStage, text: &str) -> Result<naga::Module, String> {
|
||||
let module = Frontend::default()
|
||||
.parse(&Options::from(stage), text)
|
||||
.map_err(|e| {
|
||||
e.errors
|
||||
.iter()
|
||||
.map(|e| e.kind.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
})?;
|
||||
Validator::new(ValidationFlags::all(), Capabilities::empty())
|
||||
.validate(&module)
|
||||
.map_err(|e| format!("{:?}", e.into_inner()))?;
|
||||
Ok(module)
|
||||
}
|
||||
|
||||
/// Translates the vertex and fragment shader a pass names (`minecraft:post/invert`).
|
||||
pub fn translate(root: &Path, vertex: &str, fragment: &str) -> Result<Program, String> {
|
||||
let vs = include(root, &read_source(root, vertex, "vsh")?, 0)?;
|
||||
let fs = include(root, &read_source(root, fragment, "fsh")?, 0)?;
|
||||
// Blocks and samplers are shared by name across the stages, so both agree on a binding.
|
||||
let mut blocks: Vec<(String, u32)> = Vec::new();
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for src in [&vs, &fs] {
|
||||
for (_, _, _, name, sampler) in declarations(src) {
|
||||
if sampler {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
} else if !blocks.iter().any(|(n, _)| *n == name) {
|
||||
blocks.push((name, blocks.len() as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
let first = blocks.len() as u32;
|
||||
let samplers: Vec<(String, u32)> = names.into_iter().zip((first..).step_by(2)).collect();
|
||||
let vs = wrap_main(&rewrite(&vs, &blocks, &samplers))?;
|
||||
let fs = rewrite(&fs, &blocks, &samplers);
|
||||
let vertex_module = parse(ShaderStage::Vertex, &format!("#version 450\n{vs}"))
|
||||
.map_err(|e| format!("{vertex}: {e}"))?;
|
||||
let fragment_module = parse(ShaderStage::Fragment, &format!("#version 450\n{fs}"))
|
||||
.map_err(|e| format!("{fragment}: {e}"))?;
|
||||
Ok(Program {
|
||||
vertex: vertex_module,
|
||||
fragment: fragment_module,
|
||||
blocks,
|
||||
samplers,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn root() -> Option<std::path::PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)?
|
||||
.join(".local/horizon-client/26.3");
|
||||
dir.join("assets/minecraft/shaders/post/blit.fsh")
|
||||
.exists()
|
||||
.then_some(dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_post_shaders_translate() {
|
||||
let Some(root) = root() else { return };
|
||||
let vs = ["minecraft:core/screenquad", "minecraft:post/rotscale"];
|
||||
let fs = [
|
||||
"bits",
|
||||
"blit",
|
||||
"box_blur",
|
||||
"color_convolve",
|
||||
"entity_outline_box_blur",
|
||||
"entity_sobel",
|
||||
"invert",
|
||||
"spiderclip",
|
||||
];
|
||||
for f in fs {
|
||||
for v in vs {
|
||||
let name = format!("minecraft:post/{f}");
|
||||
let p = translate(&root, v, &name);
|
||||
// rotscale's outputs only match spiderclip, but each stage translates alone.
|
||||
if let Err(e) = &p {
|
||||
panic!("{v} + {name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampler_and_block_bindings() {
|
||||
let src = "layout(std140) uniform A {\n float x;\n};\nuniform sampler2D InSampler; // c\nvoid main(){}\n";
|
||||
let d = declarations(src);
|
||||
assert_eq!(d.len(), 2);
|
||||
let out = rewrite(src, &[("A".into(), 0)], &[("InSampler".into(), 1)]);
|
||||
assert!(out.contains("layout(std140, set = 0, binding = 0) uniform A {"));
|
||||
assert!(out.contains("binding = 1) uniform texture2D InSampler_hz_tex;"));
|
||||
assert!(out.contains("binding = 2) uniform sampler InSampler_hz_smp;"));
|
||||
assert!(wrap_main("void main() {}").unwrap().contains("hz_main"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Input method composition text (`PreeditEvent`, `IMEPreeditOverlay`,
|
||||
//! `TextInputManager`): the text an input method is still composing, shown
|
||||
//! under the focused text field's caret, and the area the system's candidate
|
||||
//! window is placed at.
|
||||
//!
|
||||
//! Vanilla hands each `PreeditEvent` to the focused widget, which keeps an
|
||||
//! overlay and reports its caret every frame. Horizon draws its fields from
|
||||
//! many places, so the event is kept here and every focused field calls
|
||||
//! [`report_caret`] where it draws its caret; the game draws the overlay last
|
||||
//! with [`draw_overlay`] and applies [`take_text_input_area`] to the window.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::{Style, Text},
|
||||
ui::nine_slice,
|
||||
};
|
||||
use std::{sync::Mutex, time::Instant};
|
||||
|
||||
/// `PreeditEvent`: the composition, its caret and the blocks of text the
|
||||
/// input method set apart (the one being converted is `focused_block`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PreeditEvent {
|
||||
pub full_text: String,
|
||||
/// Byte offset of the caret in `full_text` (vanilla's UTF-16 offset).
|
||||
pub caret_position: usize,
|
||||
pub blocks: Vec<String>,
|
||||
pub focused_block: usize,
|
||||
}
|
||||
|
||||
impl PreeditEvent {
|
||||
/// `PreeditEvent.fromSdlTextEditing`: `selection_start` and
|
||||
/// `selection_length` are in code points; a negative start puts the caret
|
||||
/// at the end. Empty text is no composition.
|
||||
pub fn from_sdl_text_editing(
|
||||
text: Option<&str>,
|
||||
selection_start: i32,
|
||||
selection_length: i32,
|
||||
) -> Option<Self> {
|
||||
let text = text.filter(|t| !t.is_empty())?;
|
||||
let total = text.chars().count();
|
||||
let caret_codepoint = if selection_start < 0 {
|
||||
total
|
||||
} else {
|
||||
(selection_start as usize).min(total)
|
||||
};
|
||||
let caret_char = offset_by_codepoints(text, caret_codepoint);
|
||||
let single = |caret| Self {
|
||||
full_text: text.to_owned(),
|
||||
caret_position: caret,
|
||||
blocks: vec![text.to_owned()],
|
||||
focused_block: 0,
|
||||
};
|
||||
if selection_length <= 0 {
|
||||
return Some(single(caret_char));
|
||||
}
|
||||
let end_codepoint = (caret_codepoint + selection_length as usize).min(total);
|
||||
let end_char = offset_by_codepoints(text, end_codepoint);
|
||||
if end_char <= caret_char {
|
||||
return Some(single(caret_char));
|
||||
}
|
||||
let mut blocks = Vec::new();
|
||||
let mut focused_block = 0;
|
||||
if caret_char > 0 {
|
||||
blocks.push(text[..caret_char].to_owned());
|
||||
focused_block = 1;
|
||||
}
|
||||
blocks.push(text[caret_char..end_char].to_owned());
|
||||
if end_char < text.len() {
|
||||
blocks.push(text[end_char..].to_owned());
|
||||
}
|
||||
Some(Self {
|
||||
full_text: text.to_owned(),
|
||||
caret_position: caret_char,
|
||||
blocks,
|
||||
focused_block,
|
||||
})
|
||||
}
|
||||
|
||||
/// winit's `Ime::Preedit`: `cursor` is the byte range of the part being
|
||||
/// converted, `None` when the input method hides its cursor. SDL's
|
||||
/// `TEXTEDITING` says the same in code points.
|
||||
pub fn from_winit(text: &str, cursor: Option<(usize, usize)>) -> Option<Self> {
|
||||
let (start, length) = match cursor {
|
||||
Some((from, to)) => {
|
||||
let from = from.min(text.len());
|
||||
let to = to.clamp(from, text.len());
|
||||
let start = text.get(..from).map_or(-1, |s| s.chars().count() as i32);
|
||||
let length = text.get(from..to).map_or(0, |s| s.chars().count() as i32);
|
||||
(start, length)
|
||||
}
|
||||
None => (-1, 0),
|
||||
};
|
||||
Self::from_sdl_text_editing(Some(text), start, length)
|
||||
}
|
||||
|
||||
/// `PreeditEvent.toFormattedText`.
|
||||
pub fn to_formatted_text(&self, focused_style: &Style) -> Text {
|
||||
if self.blocks.len() == 1 {
|
||||
return Text::literal(self.blocks[0].clone()).with_style(focused_style);
|
||||
}
|
||||
let mut result = Text::empty();
|
||||
for (i, block) in self.blocks.iter().enumerate() {
|
||||
let mut part = Text::literal(block.clone());
|
||||
if i == self.focused_block {
|
||||
part = part.with_style(focused_style);
|
||||
}
|
||||
result.push(part);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// `String.offsetByCodePoints(0, n)`: the byte offset after `n` characters.
|
||||
fn offset_by_codepoints(text: &str, n: usize) -> usize {
|
||||
text.char_indices().nth(n).map_or(text.len(), |(i, _)| i)
|
||||
}
|
||||
|
||||
/// The caret a field reported this frame, in GUI pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Caret {
|
||||
x: f32,
|
||||
y: f32,
|
||||
input_height: f32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// `KeyboardHandler.lastPreeditEvent`, with when the overlay was created.
|
||||
preedit: Option<(PreeditEvent, Instant)>,
|
||||
caret: Option<Caret>,
|
||||
area: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
static STATE: Mutex<State> = Mutex::new(State {
|
||||
preedit: None,
|
||||
caret: None,
|
||||
area: None,
|
||||
});
|
||||
|
||||
fn state() -> std::sync::MutexGuard<'static, State> {
|
||||
STATE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// `KeyboardHandler.textEditing`: the input method's composition changed
|
||||
/// (`None`: it ended). A new `IMEPreeditOverlay` restarts the caret blink.
|
||||
pub fn set_preedit(event: Option<PreeditEvent>) {
|
||||
let mut s = state();
|
||||
s.preedit = event.map(|e| (e, Instant::now()));
|
||||
}
|
||||
|
||||
/// Whether text is being composed.
|
||||
pub fn composing() -> bool {
|
||||
state().preedit.is_some()
|
||||
}
|
||||
|
||||
/// What the focused field's `extractRenderState` ends with: its caret is at
|
||||
/// (`x`, `y`) in GUI pixels and `input_height` tall (a line plus one).
|
||||
/// Without a composition that is the area for the candidate window; with one
|
||||
/// it is where the overlay goes ([`draw_overlay`]).
|
||||
pub fn report_caret(x: f32, y: f32, input_height: f32) {
|
||||
state().caret = Some(Caret { x, y, input_height });
|
||||
}
|
||||
|
||||
/// `IMEPreeditOverlay.extractRenderState` for the caret reported this frame,
|
||||
/// drawn on top of everything in `batch`. Without a composition the caret is
|
||||
/// the text input area (`TextInputManager.setTextInputArea`).
|
||||
pub fn draw_overlay(batch: &mut HudBatch, atlas: &HudAtlas) {
|
||||
let mut s = state();
|
||||
let Some(caret) = s.caret.take() else {
|
||||
return;
|
||||
};
|
||||
let Some((event, init)) = s.preedit.as_ref() else {
|
||||
s.area = Some([
|
||||
caret.x,
|
||||
caret.y,
|
||||
caret.x + 1.0,
|
||||
caret.y + caret.input_height,
|
||||
]);
|
||||
return;
|
||||
};
|
||||
let font = &atlas.font;
|
||||
let focused = Style {
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
let black = 0xFF00_0000;
|
||||
let text = event.to_formatted_text(&focused).with_color(black);
|
||||
let width = font.width_text(&text);
|
||||
let caret_pos = font.width(&event.full_text[..event.caret_position]);
|
||||
let blink_visible = (init.elapsed().as_millis() / 300).is_multiple_of(2);
|
||||
let line = 9.0;
|
||||
let (gui_w, gui_h) = (batch.width, batch.height);
|
||||
let mut left = caret.x;
|
||||
let mut right = left + width;
|
||||
if right > gui_w {
|
||||
left = gui_w - width;
|
||||
right = left + width;
|
||||
}
|
||||
let input_bottom = caret.y + caret.input_height;
|
||||
let mut bottom = input_bottom + 4.0 + line;
|
||||
if bottom > gui_h {
|
||||
bottom = caret.y - 4.0 - line;
|
||||
}
|
||||
let top = bottom - line;
|
||||
s.area = Some([left, top, right, bottom]);
|
||||
drop(s);
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/sprites/widget/preedit",
|
||||
left - 5.0,
|
||||
top - 5.0,
|
||||
right - left + 10.0,
|
||||
bottom - top + 10.0,
|
||||
u32::MAX,
|
||||
);
|
||||
batch.text_styled(atlas, &text, left, top, black, false);
|
||||
if blink_visible {
|
||||
batch.fill(atlas, left + caret_pos, top - 1.0, 1.0, 11.0, black);
|
||||
}
|
||||
}
|
||||
|
||||
/// The text input area (`x0, y0, x1, y1` in GUI pixels) set since the last
|
||||
/// call, for `Window.set_ime_cursor_area`.
|
||||
pub fn take_text_input_area() -> Option<[f32; 4]> {
|
||||
state().area.take()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_text_is_no_composition() {
|
||||
assert_eq!(PreeditEvent::from_sdl_text_editing(None, 0, 0), None);
|
||||
assert_eq!(PreeditEvent::from_sdl_text_editing(Some(""), 0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_start_puts_the_caret_at_the_end() {
|
||||
let e = PreeditEvent::from_sdl_text_editing(Some("abc"), -1, 0).unwrap();
|
||||
assert_eq!(
|
||||
(e.caret_position, e.blocks, e.focused_block),
|
||||
(3, vec!["abc".into()], 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caret_counts_code_points() {
|
||||
// Two code points before the caret, the second outside the BMP.
|
||||
let e = PreeditEvent::from_sdl_text_editing(Some("a\u{1F600}b"), 2, 0).unwrap();
|
||||
assert_eq!(e.caret_position, "a\u{1F600}".len());
|
||||
let past = PreeditEvent::from_sdl_text_editing(Some("ab"), 9, 0).unwrap();
|
||||
assert_eq!(past.caret_position, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_splits_the_text_into_blocks() {
|
||||
let e = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 2, 2).unwrap();
|
||||
assert_eq!(e.blocks, ["ab", "cd", "ef"]);
|
||||
assert_eq!((e.focused_block, e.caret_position), (1, 2));
|
||||
let first = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 0, 3).unwrap();
|
||||
assert_eq!(first.blocks, ["abc", "def"]);
|
||||
assert_eq!(first.focused_block, 0);
|
||||
let last = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 3, 99).unwrap();
|
||||
assert_eq!(last.blocks, ["abc", "def"]);
|
||||
assert_eq!(last.focused_block, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winit_byte_ranges_become_code_points() {
|
||||
let text = "\u{3042}\u{3044}\u{3046}";
|
||||
// Converting the middle character (bytes 3..6).
|
||||
let e = PreeditEvent::from_winit(text, Some((3, 6))).unwrap();
|
||||
assert_eq!(e.blocks, ["\u{3042}", "\u{3044}", "\u{3046}"]);
|
||||
assert_eq!(e.focused_block, 1);
|
||||
// A hidden cursor sits at the end.
|
||||
let hidden = PreeditEvent::from_winit(text, None).unwrap();
|
||||
assert_eq!(hidden.caret_position, text.len());
|
||||
assert_eq!(hidden.blocks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_focused_block_is_underlined() {
|
||||
let focused = Style {
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
let e = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 2, 2).unwrap();
|
||||
let styled: Vec<(char, bool)> = e
|
||||
.to_formatted_text(&focused)
|
||||
.chars()
|
||||
.map(|(c, s)| (c, s.underlined == Some(true)))
|
||||
.collect();
|
||||
let underlined: String = styled.iter().filter(|x| x.1).map(|x| x.0).collect();
|
||||
assert_eq!(underlined, "cd");
|
||||
assert_eq!(styled.len(), 6);
|
||||
let one = PreeditEvent::from_sdl_text_editing(Some("ab"), 0, 0).unwrap();
|
||||
assert!(
|
||||
one.to_formatted_text(&focused)
|
||||
.chars()
|
||||
.all(|(_, s)| s.underlined == Some(true))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! dimension type allows.
|
||||
|
||||
/// Section indexes of one column, 0 being the lowest.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct SectionMask([u64; 4]);
|
||||
|
||||
impl SectionMask {
|
||||
|
||||
@@ -497,10 +497,13 @@ impl Settings {
|
||||
/// `Options.createInformation`: what the server learns about this player.
|
||||
pub fn client_information(&self, view_distance: i8) -> ClientInformation {
|
||||
ClientInformation {
|
||||
language: self.language.clone(),
|
||||
chat_visibility: self.chat_visibility as i32,
|
||||
chat_colors: self.chat_colors,
|
||||
model_customisation: self.file.model_customisation(),
|
||||
main_hand: i32::from(!self.main_hand_left),
|
||||
allows_listing: self.file.bool("allowServerListing"),
|
||||
particle_status: i32::from(self.particles),
|
||||
..ClientInformation::new(view_distance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,6 +958,21 @@ impl Streaming {
|
||||
}
|
||||
Some(Box::new(region))
|
||||
}
|
||||
/// Whether `light_region` would find all nine columns: the same walk
|
||||
/// without building the snapshot, so the scheduler can rank the ready
|
||||
/// columns before any region is cloned. A proto neighbour whose light
|
||||
/// view turns out empty is skipped when its region is built, and asked
|
||||
/// for again, as `light_region` returning `None` always was.
|
||||
fn light_region_ready(&self, world: &World, x: i32, z: i32) -> bool {
|
||||
light_region_ready(
|
||||
world,
|
||||
|x, z| self.in_range(x, z, 1),
|
||||
&self.decorated,
|
||||
&self.protos,
|
||||
x,
|
||||
z,
|
||||
)
|
||||
}
|
||||
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
for dz in 0..3 {
|
||||
@@ -1357,13 +1372,29 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
self.schedule_generation(world, workers);
|
||||
// Lighting once all neighbours exist.
|
||||
let light: Vec<_> = world.dirty_light.iter().copied().collect();
|
||||
for (x, z) in light {
|
||||
// Lighting once all neighbours exist, nearest first and only as
|
||||
// much of it as the workers can chew. Vanilla's light engine also
|
||||
// hands a bounded queue to its workers and reschedules what did
|
||||
// not fit by distance; submitted whole, the light jobs of every
|
||||
// unlit column at once — each carrying a 3x3 region snapshot —
|
||||
// grew with the render distance and outlived the columns they
|
||||
// were queued for (#3629). What stays in `dirty_light` is asked
|
||||
// for again, from the current centre, as capacity frees.
|
||||
let mut light: Vec<(i64, i32, i32)> = Vec::new();
|
||||
let mut gone: Vec<(i32, i32)> = Vec::new();
|
||||
for &(x, z) in world.dirty_light.iter() {
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
world.dirty_light.remove(&(x, z));
|
||||
continue;
|
||||
gone.push((x, z));
|
||||
} else if self.light_region_ready(world, x, z) {
|
||||
light.push((self.distance(x, z), x, z));
|
||||
}
|
||||
}
|
||||
for key in gone {
|
||||
world.dirty_light.remove(&key);
|
||||
}
|
||||
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
|
||||
nearest_first(&mut light, room);
|
||||
for (_, x, z) in light {
|
||||
let Some(region) = self.light_region(world, x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1409,6 +1440,7 @@ impl Streaming {
|
||||
}
|
||||
// Column by column: the checks below hold for all of a column's
|
||||
// sections, and columns waiting for light cost one look per frame.
|
||||
let mut candidates: Vec<(i64, i32, i32, SectionMask)> = Vec::new();
|
||||
let columns: Vec<_> = world.dirty_sections.columns().collect();
|
||||
for ((x, z), sections) in columns {
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
@@ -1437,6 +1469,18 @@ impl Streaming {
|
||||
if !ready {
|
||||
continue;
|
||||
}
|
||||
candidates.push((self.distance(x, z), x, z, sections));
|
||||
}
|
||||
// Nearest first, and only as many sections as the workers can chew.
|
||||
// Every ready column used to be submitted at once, so at a wide
|
||||
// render distance the queue — and `meshing`, and each job's 3x3
|
||||
// region snapshot — grew with the number of loaded columns and the
|
||||
// jobs went stale long before a worker reached them (#3629). What
|
||||
// stays dirty is asked for again, from the current centre, as
|
||||
// capacity frees; `requeue` then has a small queue to keep close.
|
||||
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
|
||||
nearest_first(&mut candidates, room);
|
||||
for (_, x, z, sections) in candidates {
|
||||
let Some(region) = Self::region(world, x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -2051,6 +2095,46 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
|
||||
}
|
||||
*queue = BinaryHeap::from(jobs);
|
||||
}
|
||||
/// Whether the 3x3 a light job for (x, z) reads is all there: every
|
||||
/// neighbour a finished column, or a decorated proto chunk past the area
|
||||
/// (the last FULL ring lights against neighbours at FEATURES, as
|
||||
/// `Streaming::light_region` reads them).
|
||||
fn light_region_ready(
|
||||
world: &World,
|
||||
in_range: impl Fn(i32, i32) -> bool,
|
||||
decorated: &FxHashSet<(i32, i32)>,
|
||||
protos: &FxHashMap<(i32, i32), Option<Proto>>,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> bool {
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
let n = (x + dx - 1, z + dz - 1);
|
||||
if world.chunks.contains_key(&n)
|
||||
|| (!in_range(n.0, n.1)
|
||||
&& decorated.contains(&n)
|
||||
&& matches!(protos.get(&n), Some(Some(_))))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The nearest `room` of the `candidates`, ordered: the light and mesh
|
||||
/// stages submit bounded work, chosen from everything that is ready each
|
||||
/// `update`, so what runs is always the nearest of what is left — chosen
|
||||
/// again, from the current centre, as capacity frees, the way vanilla's
|
||||
/// chunk task queues follow the current ticket levels. The selection is
|
||||
/// O(n); the sort orders only what is submitted.
|
||||
fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
|
||||
if candidates.len() > room {
|
||||
candidates.select_nth_unstable(room);
|
||||
candidates.truncate(room);
|
||||
}
|
||||
candidates.sort_unstable();
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2140,6 +2224,79 @@ mod tests {
|
||||
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
|
||||
}
|
||||
|
||||
/// The light and mesh stages submit bounded work, the nearest first:
|
||||
/// what exceeds the room stays dirty, to be chosen again — from the
|
||||
/// current centre — as capacity frees. Submitted whole, the queue once
|
||||
/// held every ready column's jobs and grew with the render distance
|
||||
/// (#3629); ties break by position, so the choice is deterministic.
|
||||
#[test]
|
||||
fn bounded_submission_takes_the_nearest_first() {
|
||||
let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32)
|
||||
.map(|i| {
|
||||
let (x, z) = (i / 8 - 2, i % 8 - 3);
|
||||
(
|
||||
i64::from((x - 3).pow(2) + (z - 2).pow(2)),
|
||||
x,
|
||||
z,
|
||||
1 << (i % 24),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut expected = candidates.clone();
|
||||
expected.sort_unstable();
|
||||
let mut none = candidates.clone();
|
||||
nearest_first(&mut none, 0);
|
||||
assert!(none.is_empty());
|
||||
let mut nearest = candidates;
|
||||
nearest_first(&mut nearest, 5);
|
||||
assert_eq!(nearest, expected[..5]);
|
||||
let mut past = expected.clone();
|
||||
nearest_first(&mut past, expected.len() + 10);
|
||||
assert_eq!(past, expected);
|
||||
}
|
||||
|
||||
/// What the scheduler counts as ready to light: every neighbour a
|
||||
/// finished column, or a decorated proto chunk past the area — the
|
||||
/// walk `light_region` makes without its snapshot.
|
||||
#[test]
|
||||
fn light_readiness_follows_the_region_a_light_job_reads() {
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let nine = (-1..=1).flat_map(|dz| (-1..=1).map(move |dx| (dx, dz)));
|
||||
for key in nine {
|
||||
world.chunks.insert(key, chunk.clone());
|
||||
}
|
||||
// The kept area is the cross around (0, 0); the corners of the
|
||||
// 3x3 light reads lie past it.
|
||||
let in_range = |x: i32, z: i32| x.abs() + z.abs() <= 1;
|
||||
let none = FxHashSet::<(i32, i32)>::default();
|
||||
let empty = FxHashMap::<(i32, i32), Option<Proto>>::default();
|
||||
assert!(light_region_ready(&world, in_range, &none, &empty, 0, 0));
|
||||
// A corner is missing, and nothing stands in for it yet.
|
||||
world.chunks.remove(&(1, 1));
|
||||
assert!(!light_region_ready(&world, in_range, &none, &empty, 0, 0));
|
||||
// A decorated, checked-in proto chunk past the area lights against.
|
||||
let decorated: FxHashSet<(i32, i32)> = [(1, 1)].into_iter().collect();
|
||||
let mut protos: FxHashMap<(i32, i32), Option<Proto>> = Default::default();
|
||||
protos.insert((1, 1), Some(Box::new(()) as Proto));
|
||||
assert!(light_region_ready(
|
||||
&world, in_range, &decorated, &protos, 0, 0
|
||||
));
|
||||
// The proto is checked out to a job: it cannot be read.
|
||||
protos.insert((1, 1), None);
|
||||
assert!(!light_region_ready(
|
||||
&world, in_range, &decorated, &protos, 0, 0
|
||||
));
|
||||
// A neighbour inside the area waits for the chunk itself.
|
||||
world.chunks.remove(&(1, 0));
|
||||
let mut inside = decorated.clone();
|
||||
inside.insert((1, 0));
|
||||
protos.insert((1, 0), Some(Box::new(()) as Proto));
|
||||
assert!(!light_region_ready(
|
||||
&world, in_range, &inside, &protos, 0, 0
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_distance_is_vanillas_shape() {
|
||||
// ChunkTrackingView.isInViewDistance at render distance 12: a column
|
||||
|
||||
@@ -27,6 +27,9 @@ struct Globals {
|
||||
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
|
||||
// triangle's volume with the camera; then each rim vertex's normal against the apex.
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
// x: Options.textureFiltering (0 none, 1 RGSS, 2 anisotropic), y: the sprite arrays' width
|
||||
// in texels (vanilla's TextureSize).
|
||||
filtering: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -45,14 +48,12 @@ struct Globals {
|
||||
// which then samples exactly as before.
|
||||
override SPRITE_ARRAYS: u32 = 1u;
|
||||
override SPRITE_SPLIT: u32 = 2048u;
|
||||
fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
|
||||
if SPRITE_ARRAYS == 1u {
|
||||
return textureSample(sprites, sprite_sampler, uv, i32(layer));
|
||||
}
|
||||
// The array is chosen per fragment: sample with the derivatives taken here.
|
||||
let dx = dpdx(uv);
|
||||
let dy = dpdy(uv);
|
||||
// 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);
|
||||
if SPRITE_ARRAYS == 1u {
|
||||
return textureSampleGrad(sprites, sprite_sampler, uv, l, dx, dy);
|
||||
}
|
||||
switch layer / SPRITE_SPLIT {
|
||||
case 0u: { return textureSampleGrad(sprites, sprite_sampler, uv, l, dx, dy); }
|
||||
case 1u: { return textureSampleGrad(sprites_1, sprite_sampler, uv, l, dx, dy); }
|
||||
@@ -64,6 +65,78 @@ fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
|
||||
default: { return textureSampleGrad(sprites_7, sprite_sampler, uv, l, dx, dy); }
|
||||
}
|
||||
}
|
||||
// The same at an explicit mip level.
|
||||
fn sprite_level(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
|
||||
let l = i32(layer % SPRITE_SPLIT);
|
||||
if SPRITE_ARRAYS == 1u {
|
||||
return textureSampleLevel(sprites, sprite_sampler, uv, l, lod);
|
||||
}
|
||||
switch layer / SPRITE_SPLIT {
|
||||
case 0u: { return textureSampleLevel(sprites, sprite_sampler, uv, l, lod); }
|
||||
case 1u: { return textureSampleLevel(sprites_1, sprite_sampler, uv, l, lod); }
|
||||
case 2u: { return textureSampleLevel(sprites_2, sprite_sampler, uv, l, lod); }
|
||||
case 3u: { return textureSampleLevel(sprites_3, sprite_sampler, uv, l, lod); }
|
||||
case 4u: { return textureSampleLevel(sprites_4, sprite_sampler, uv, l, lod); }
|
||||
case 5u: { return textureSampleLevel(sprites_5, sprite_sampler, uv, l, lod); }
|
||||
case 6u: { return textureSampleLevel(sprites_6, sprite_sampler, uv, l, lod); }
|
||||
default: { return textureSampleLevel(sprites_7, sprite_sampler, uv, l, lod); }
|
||||
}
|
||||
}
|
||||
// texture_sampling.glsl's sampleNearest: the sprite's texels stay square pixels, blending only
|
||||
// across the one screen pixel where two meet, so a linear (and anisotropic) sampler keeps them crisp.
|
||||
fn sample_nearest(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
|
||||
texel_screen: vec2<f32>) -> vec4<f32> {
|
||||
let texel_coords = uv / pixel;
|
||||
let center = round(texel_coords) - 0.5;
|
||||
var offset = texel_coords - center;
|
||||
offset = (offset - 0.5) * pixel / max(texel_screen, vec2(1e-12)) + 0.5;
|
||||
offset = clamp(offset, vec2(0.0), vec2(1.0));
|
||||
return sprite_grad((center + offset) * pixel, layer, du, dv);
|
||||
}
|
||||
// sampleRGSS: four rotated-grid samples of the two nearest mip levels, faded in as the texels
|
||||
// shrink below two screen pixels, over sample_nearest.
|
||||
fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
|
||||
texel_screen: vec2<f32>) -> vec4<f32> {
|
||||
let max_texel = max(texel_screen.x, texel_screen.y);
|
||||
let min_pixel = min(pixel.x, pixel.y);
|
||||
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
|
||||
let du_len = length(du);
|
||||
let dv_len = length(dv);
|
||||
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
|
||||
let exact = max(0.0, log2(effective / min_pixel));
|
||||
let low = floor(exact);
|
||||
let mip_blend = fract(exact);
|
||||
var offsets = array<vec2<f32>, 4>(
|
||||
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
|
||||
var sum_low = vec4(0.0);
|
||||
var sum_high = vec4(0.0);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
let p = uv + offsets[i] * pixel;
|
||||
sum_low += sprite_level(p, layer, low);
|
||||
sum_high += sprite_level(p, layer, low + 1.0);
|
||||
}
|
||||
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
|
||||
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
|
||||
}
|
||||
fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
|
||||
let mode = u32(globals.filtering.x);
|
||||
if mode != 0u {
|
||||
// Texture Filtering Anisotropic or RGSS: terrain.fsh's two sampling paths.
|
||||
let du = dpdx(uv);
|
||||
let dv = dpdy(uv);
|
||||
let pixel = vec2(1.0 / globals.filtering.y);
|
||||
let texel_screen = sqrt(du * du + dv * dv);
|
||||
if mode == 1u {
|
||||
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
|
||||
}
|
||||
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
|
||||
}
|
||||
if SPRITE_ARRAYS == 1u {
|
||||
return textureSample(sprites, sprite_sampler, uv, i32(layer));
|
||||
}
|
||||
// The array is chosen per fragment: sample with the derivatives taken here.
|
||||
return sprite_grad(uv, layer, dpdx(uv), dpdy(uv));
|
||||
}
|
||||
|
||||
struct TerrainOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
|
||||
@@ -1289,6 +1289,9 @@ impl EditBox {
|
||||
batch.text(atlas, "_", cursor_x, ty, color, true);
|
||||
}
|
||||
}
|
||||
if self.focused {
|
||||
crate::preedit::report_caret(cursor_x, ty, 10.0);
|
||||
}
|
||||
}
|
||||
/// Character index under GUI x (mouse click).
|
||||
pub fn index_at(&self, atlas: &HudAtlas, x: f32) -> usize {
|
||||
|
||||
@@ -64,10 +64,11 @@ horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
|
||||
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
# `LevelStorageAccess.estimateDiskSpace` (statvfs) too.
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
||||
@@ -63,5 +63,137 @@
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
|
||||
"horizon.multiplayer.join_friend": "Join a Friend",
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)"
|
||||
"horizon.multiplayer.relay": "Relay (optional)",
|
||||
"Preset": "Preset",
|
||||
"LOD Distance": "LOD Distance",
|
||||
"Far Detail": "Far Detail",
|
||||
"Cave Culling": "Cave Culling",
|
||||
"Entity Culling": "Entity Culling",
|
||||
"Chunk Updates": "Chunk Updates",
|
||||
"Upload Budget": "Upload Budget",
|
||||
"Sky": "Sky",
|
||||
"Fog": "Fog",
|
||||
"Weather": "Weather",
|
||||
"Animated Textures": "Animated Textures",
|
||||
"Present Mode": "Present Mode",
|
||||
"Frame Pacing": "Frame Pacing",
|
||||
"Worker Threads": "Worker Threads",
|
||||
"Resolution Scale": "Resolution Scale",
|
||||
"Anti-aliasing": "Anti-aliasing",
|
||||
"Upscaling": "Upscaling",
|
||||
"HDR Settings...": "HDR Settings...",
|
||||
"Paper White": "Paper White",
|
||||
"Peak Brightness": "Peak Brightness",
|
||||
"Black Level": "Black Level",
|
||||
"UI Brightness": "UI Brightness",
|
||||
"Battery Mode": "Battery Mode",
|
||||
"Graphics API": "Graphics API",
|
||||
"Performance Overlay": "Performance Overlay",
|
||||
"Full Detail Distance": "Full Detail Distance",
|
||||
"On": "On",
|
||||
"Off": "Off",
|
||||
"Unlimited": "Unlimited",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Custom",
|
||||
"Performance": "Performance",
|
||||
"Balanced": "Balanced",
|
||||
"Quality": "Quality",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra Performance",
|
||||
"Native": "Native",
|
||||
"Full": "Full",
|
||||
"Reduced": "Reduced",
|
||||
"Efficient": "Efficient",
|
||||
"Precise": "Precise",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS and Graph",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "No cost",
|
||||
"Low cost": "Low cost",
|
||||
"Medium cost": "Medium cost",
|
||||
"High cost": "High cost",
|
||||
"Saves time": "Saves time",
|
||||
"Rendering": "Rendering",
|
||||
"Display": "Display",
|
||||
"Preferences": "Preferences",
|
||||
"Overlay": "Overlay",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Graphics",
|
||||
"Graphics: Advanced": "Graphics: Advanced",
|
||||
"Graphics: HDR": "Graphics: HDR",
|
||||
"Advanced...": "Advanced...",
|
||||
"Simple...": "Simple...",
|
||||
"Done": "Done",
|
||||
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
|
||||
"Turn HDR on to use it.": "Turn HDR on to use it.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s per frame",
|
||||
"%s MiB/frame": "%s MiB/frame",
|
||||
"%s KiB/frame": "%s KiB/frame",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Full-detail terrain range. LOD Distance can extend local worlds farther.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduced draws far terrain coarser: faster on this integrated GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduced draws far terrain coarser, for GPUs it slows down.",
|
||||
"Field of view. Wider shows more terrain at once.": "Field of view. Wider shows more terrain at once.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Caps frames per second; a cap saves power and heat.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Waits for the display's refresh: no tearing, a little latency.",
|
||||
"Size of menus and the HUD.": "Size of menus and the HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Sets the options below together. Changing one makes it Custom.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "How bright highlights get. Auto: the peak your system reports.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "How bright white paper and menus look. Auto: your desktop's white.",
|
||||
"How bright the HUD and menus are, against paper white.": "How bright the HUD and menus are, against paper white.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Blends grass and water colours across biomes when building chunks.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Cloud layer: off, flat (fast) or 3D (fancy).",
|
||||
"How far clouds reach, in chunks.": "How far clouds reach, in chunks.",
|
||||
"Draws the sky, sun, moon and stars.": "Draws the sky, sun, moon and stars.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Distance fog. Off shows the edge of the loaded world.",
|
||||
"Draws rain and snow.": "Draws rain and snow.",
|
||||
"How far rain and snow fall around you.": "How far rain and snow fall around you.",
|
||||
"How many particles effects spawn.": "How many particles effects spawn.",
|
||||
"Round shadows under mobs and items.": "Round shadows under mobs and items.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Leaves with see-through gaps. Off draws them solid.",
|
||||
"Sorts water and glass more exactly behind each other.": "Sorts water and glass more exactly behind each other.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Which chunk rebuilds wait for the frame: none, nearby or yours.",
|
||||
"Animates water, lava, fire and other moving textures.": "Animates water, lava, fire and other moving textures.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "World pixels per screen pixel. Below 100% is faster on the GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR output on an HDR display: lava, fire and sunlight shine brighter.",
|
||||
"How frames reach the screen while VSync is off.": "How frames reach the screen while VSync is off.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efficient sleeps until the next frame; Precise spins, like vanilla.",
|
||||
"Fills the screen (borderless).": "Fills the screen (borderless).",
|
||||
"Brightens dark places.": "Brightens dark places.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Lowers the frame rate when minimised, or idle too.",
|
||||
"Sways the view while walking.": "Sways the view while walking.",
|
||||
"Fades newly loaded chunks in.": "Fades newly loaded chunks in.",
|
||||
"Darkens the screen's edges.": "Darkens the screen's edges.",
|
||||
"Shows when the world saves.": "Shows when the world saves.",
|
||||
"Shows attack cooldown.": "Shows attack cooldown.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS cap (30 in menus or unfocused), half the workers: less power.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads that generate, light and mesh chunks. Next start.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph."
|
||||
}
|
||||
@@ -63,5 +63,137 @@
|
||||
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
|
||||
"horizon.multiplayer.join_friend": "Играть с другом",
|
||||
"horizon.multiplayer.invite_code": "Код приглашения",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необязательно)",
|
||||
"Preset": "Пресет",
|
||||
"LOD Distance": "Дистанция LOD",
|
||||
"Far Detail": "Детализация вдали",
|
||||
"Cave Culling": "Отсечение пещер",
|
||||
"Entity Culling": "Отсечение сущностей",
|
||||
"Chunk Updates": "Обновления чанков",
|
||||
"Upload Budget": "Бюджет выгрузки",
|
||||
"Sky": "Небо",
|
||||
"Fog": "Туман",
|
||||
"Weather": "Погода",
|
||||
"Animated Textures": "Анимированные текстуры",
|
||||
"Present Mode": "Режим вывода",
|
||||
"Frame Pacing": "Ритм кадров",
|
||||
"Worker Threads": "Рабочие потоки",
|
||||
"Resolution Scale": "Масштаб разрешения",
|
||||
"Anti-aliasing": "Сглаживание",
|
||||
"Upscaling": "Апскейл",
|
||||
"HDR Settings...": "Настройки HDR…",
|
||||
"Paper White": "Белая точка",
|
||||
"Peak Brightness": "Пиковая яркость",
|
||||
"Black Level": "Уровень чёрного",
|
||||
"UI Brightness": "Яркость интерфейса",
|
||||
"Battery Mode": "Режим экономии",
|
||||
"Graphics API": "Графический API",
|
||||
"Performance Overlay": "Оверлей производительности",
|
||||
"Full Detail Distance": "Дальность полной детализации",
|
||||
"On": "Вкл.",
|
||||
"Off": "Выкл.",
|
||||
"Unlimited": "Без ограничения",
|
||||
"Auto": "Авто",
|
||||
"Custom": "Свои",
|
||||
"Performance": "Производительность",
|
||||
"Balanced": "Сбалансированный",
|
||||
"Quality": "Качество",
|
||||
"Ultra": "Ультра",
|
||||
"Ultra Performance": "Максимальная производительность",
|
||||
"Native": "Нативный",
|
||||
"Full": "Полная",
|
||||
"Reduced": "Снижена",
|
||||
"Efficient": "Экономный",
|
||||
"Precise": "Точный",
|
||||
"Auto (VSync)": "Авто (верт. синхр.)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS и график",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Без затрат",
|
||||
"Low cost": "Мало затрат",
|
||||
"Medium cost": "Средние затраты",
|
||||
"High cost": "Много затрат",
|
||||
"Saves time": "Экономит время",
|
||||
"Rendering": "Рендеринг",
|
||||
"Display": "Изображение",
|
||||
"Preferences": "Предпочтения",
|
||||
"Overlay": "Оверлей",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Графика",
|
||||
"Graphics: Advanced": "Графика: подробно",
|
||||
"Graphics: HDR": "Графика: HDR",
|
||||
"Advanced...": "Подробно…",
|
||||
"Simple...": "Просто…",
|
||||
"Done": "Готово",
|
||||
"This feature is not yet available in Horizon.": "Эта функция в Horizon пока недоступна.",
|
||||
"Your display or system doesn't offer HDR output.": "Ваш экран или система не поддерживает HDR-вывод.",
|
||||
"Turn HDR on to use it.": "Включите HDR, чтобы пользоваться этим.",
|
||||
"%s chunks": "%s чанков",
|
||||
"%s per frame": "%s за кадр",
|
||||
"%s MiB/frame": "%s МиБ/кадр",
|
||||
"%s KiB/frame": "%s КиБ/кадр",
|
||||
"%s nits": "%s нит",
|
||||
"Auto (%s)": "Авто (%s)",
|
||||
"Auto (%s nits)": "Авто (%s нит)",
|
||||
"%.2f nits": "%.2f нит",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальность прорисованного рельефа в деталях. Дистанция LOD может продлить её для локальных миров.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Грубый дальний рельеф в локальных мирах. На встроенной графике — не дальше 128 чанков.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "«Снижено» рисует дальний рельеф грубее: на этой встроенной видеокарте быстрее.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "«Снижено» рисует дальний рельеф грубее — для карт, которым он мешает.",
|
||||
"Field of view. Wider shows more terrain at once.": "Угол обзора. Шире — больше рельефа видно сразу.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Ограничивает кадры в секунду: предел экономит энергию и снижает нагрев.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Ждёт обновления экрана: без разрывов, с небольшой задержкой.",
|
||||
"Size of menus and the HUD.": "Размер меню и интерфейса.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Задаёт настройки ниже разом. Изменишь одну — станет «Свои».",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, огонь и солнце светятся ярче белого. Нужен HDR-экран.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Самый тёмный чёрный вашего экрана. 0 для OLED; ЖК-панели светятся немного.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Насколько яркими будут блики. Авто: пик, который сообщает система.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Насколько ярким выглядит белый и меню. Авто: белый цвет рабочего стола.",
|
||||
"How bright the HUD and menus are, against paper white.": "Яркость интерфейса и меню относительно белой точки.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Прячет модели за непрозрачными блоками. Сущности тикают. Выключать со своими наборами.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Смешивает цвета травы и воды на границах биомов при построении чанков.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Слой облаков: выключен, плоский (быстро) или 3D (красиво).",
|
||||
"How far clouds reach, in chunks.": "Как далеко простираются облака, в чанках.",
|
||||
"Draws the sky, sun, moon and stars.": "Рисует небо, солнце, луну и звёзды.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Туман по дальности. Выключенный виден край прогруженного мира.",
|
||||
"Draws rain and snow.": "Рисует дождь и снег.",
|
||||
"How far rain and snow fall around you.": "Как далеко вокруг вас идёт дождь и снег.",
|
||||
"How many particles effects spawn.": "Сколько частиц создают эффекты.",
|
||||
"Round shadows under mobs and items.": "Круглые тени под мобами и предметами.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Листва с просветами. Выключено — рисуется сплошной.",
|
||||
"Sorts water and glass more exactly behind each other.": "Точнее сортирует воду и стекло друг за другом.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Какие перестройки чанков ждут кадр: никакие, ближние или ваши.",
|
||||
"Animates water, lava, fire and other moving textures.": "Анимирует воду, лаву, огонь и другие движущиеся текстуры.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пикселей мира на пиксель экрана. Ниже 100% — быстрее для видеокарты.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Сглаживает рваные края. FXAA: мягче. TAA: глаже всего, но движущееся может шлейфить.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Рисует мир мельче и восстанавливает его через TAA. Быстрее, но мягче.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-вывод на HDR-экране: лава, огонь и солнце светятся ярче.",
|
||||
"How frames reach the screen while VSync is off.": "Как кадры попадают на экран, когда вертикальная синхронизация выключена.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Экономный» спит до следующего кадра; «Точный» крутится, как в ванили.",
|
||||
"Fills the screen (borderless).": "Занимает весь экран (без рамки).",
|
||||
"Brightens dark places.": "Осветляет тёмные места.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Снижает частоту кадров в свёрнутом окне, а также в простое.",
|
||||
"Sways the view while walking.": "Покачивает вид при ходьбе.",
|
||||
"Fades newly loaded chunks in.": "Плавно проявляет только что загруженные чанки.",
|
||||
"Darkens the screen's edges.": "Затемняет края экрана.",
|
||||
"Shows when the world saves.": "Показывает, когда мир сохраняется.",
|
||||
"Shows attack cooldown.": "Показывает перезарядку удара.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Предел 60 FPS (30 в меню и без фокуса) и половина потоков: меньше энергии.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Потоки, которые генерируют, освещают и строят чанки. Со следующего запуска.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, при сбое — DirectX 12 (Windows) или OpenGL (Linux). Со следующего запуска.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком."
|
||||
}
|
||||
@@ -599,6 +599,7 @@ fn print_heart(
|
||||
fn player_damage(e: &MobEvent) -> f32 {
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _)
|
||||
| MobEvent::CubeHurtPlayer { amount, .. }
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
MobEvent::SonicBoom {
|
||||
@@ -616,6 +617,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
};
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _)
|
||||
| MobEvent::CubeHurtPlayer { amount, .. }
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
MobEvent::SonicBoom {
|
||||
@@ -1027,6 +1029,10 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
"inlove" => m.extra.in_love = 600,
|
||||
"nectar" => horizon_client::mobs::bee::set_has_nectar(&mut m, true),
|
||||
"tame" => m.extra.tame = true,
|
||||
"owned" => {
|
||||
horizon_client::mobs::tame::set_tame(&mut m, true);
|
||||
m.extra.owner = Some(PLAYER_UUID);
|
||||
}
|
||||
x if x.starts_with("age=") => m.extra.age = x[4..].parse().unwrap(),
|
||||
x if x.starts_with("strength=") => m.extra.variant2 = x[9..].parse().unwrap(),
|
||||
x if x.starts_with("hand=") => {
|
||||
|
||||
@@ -220,7 +220,7 @@ pub fn main() -> Result<()> {
|
||||
}
|
||||
writeln!(out)?;
|
||||
}
|
||||
for (name, p, volume, pitch) in std::mem::take(&mut engine.sounds) {
|
||||
for (name, p, volume, pitch, _) in std::mem::take(&mut engine.sounds) {
|
||||
writeln!(
|
||||
out,
|
||||
"~ {name} {} {} {} {} {}",
|
||||
|
||||
@@ -536,7 +536,7 @@ fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
|
||||
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
|
||||
}
|
||||
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
|
||||
engine.sounds.push((name, p, volume, pitch));
|
||||
engine.sounds.push((name, p, volume, pitch, false));
|
||||
}
|
||||
fn redstone(engine: &mut ParticleEngine, p: [f64; 3]) {
|
||||
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 1.0), p, [0.0; 3]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `BlockPattern`: a grid of block predicates found in the world in any of
|
||||
//! its 24 orientations (upright, upside down, lying flat, turned sideways).
|
||||
//! The wither is built from these.
|
||||
//! The wither and the golems are built from these.
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
/// A grid of predicate keys: `aisles[z][y]` is a row whose chars are the `x`
|
||||
@@ -19,6 +19,25 @@ pub const WITHER_FULL: BlockPattern = BlockPattern {
|
||||
pub const WITHER_BASE: BlockPattern = BlockPattern {
|
||||
aisles: &[&[" ", "###", "~#~"]],
|
||||
};
|
||||
/// `CarvedPumpkinBlock.getOrCreateSnowGolemFull`: '^' a carved pumpkin or
|
||||
/// jack o'lantern, '#' a snow block.
|
||||
pub const SNOW_GOLEM_FULL: BlockPattern = BlockPattern {
|
||||
aisles: &[&["^", "#", "#"]],
|
||||
};
|
||||
/// `CarvedPumpkinBlock.getOrCreateSnowGolemBase`: the same column with any
|
||||
/// block where the head will go.
|
||||
pub const SNOW_GOLEM_BASE: BlockPattern = BlockPattern {
|
||||
aisles: &[&[" ", "#", "#"]],
|
||||
};
|
||||
/// `CarvedPumpkinBlock.getOrCreateIronGolemFull`: '^' the head, '#' an iron
|
||||
/// block, '~' air beside the arms and legs.
|
||||
pub const IRON_GOLEM_FULL: BlockPattern = BlockPattern {
|
||||
aisles: &[&["~^~", "###", "~#~"]],
|
||||
};
|
||||
/// `CarvedPumpkinBlock.getOrCreateIronGolemBase`.
|
||||
pub const IRON_GOLEM_BASE: BlockPattern = BlockPattern {
|
||||
aisles: &[&["~ ~", "###", "~#~"]],
|
||||
};
|
||||
/// `BlockPattern.BlockPatternMatch`: where a pattern was found and how it is turned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PatternMatch {
|
||||
|
||||
@@ -882,6 +882,10 @@ pub struct ArmorStand {
|
||||
/// `DATA_HEAD_POSE` to `DATA_RIGHT_LEG_POSE`: head, body, left arm, right
|
||||
/// arm, left leg, right leg, in degrees (`POSE_KEYS` order).
|
||||
pub pose: [[f32; 3]; 6],
|
||||
/// `Entity.vehicle` on a server's client (`SetPassengers`): the mob it
|
||||
/// rides, which seats it every tick (`positionRider`), as NPC plugins hang
|
||||
/// a second name over a player's head.
|
||||
pub vehicle: Option<u32>,
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
@@ -916,6 +920,7 @@ impl ArmorStand {
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
pose: Self::DEFAULT_POSE,
|
||||
vehicle: None,
|
||||
}
|
||||
}
|
||||
pub fn environment_tick(
|
||||
|
||||
+393
-111
@@ -119,6 +119,7 @@ mod glowing;
|
||||
mod golems;
|
||||
mod growth;
|
||||
mod hand;
|
||||
mod ime;
|
||||
mod initial_spawn;
|
||||
mod integrated;
|
||||
mod interactions;
|
||||
@@ -247,6 +248,8 @@ mod subtitle_hud;
|
||||
mod sulfur_cubes;
|
||||
mod test_block_edit;
|
||||
mod test_blocks;
|
||||
mod test_entities;
|
||||
mod test_environments;
|
||||
mod test_instance_edit;
|
||||
mod test_instances;
|
||||
mod text_displays;
|
||||
@@ -344,6 +347,12 @@ use winit::{
|
||||
window::{CursorGrabMode, Window, WindowId},
|
||||
};
|
||||
|
||||
/// The time a frame spends uploading chunk meshes when the player set no
|
||||
/// Chunk Updates or Upload Budget: a burst past it waits for the next frames.
|
||||
const AUTO_UPLOAD_TIME: Duration = Duration::from_millis(2);
|
||||
/// A frame's finished meshes up to this many upload at once, unqueued.
|
||||
const AUTO_UPLOADS_AT_ONCE: usize = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Options {
|
||||
resources: PathBuf,
|
||||
@@ -787,15 +796,7 @@ struct GameLevel<'w> {
|
||||
client_side: bool,
|
||||
/// Only the integrated or dedicated server dispatches native observations.
|
||||
native_authoritative: bool,
|
||||
native_particles: &'w mut Vec<(
|
||||
&'static str,
|
||||
horizon_client::particles::ParticleOptions,
|
||||
Option<BlockStateId>,
|
||||
[f64; 3],
|
||||
[f64; 3],
|
||||
f64,
|
||||
i32,
|
||||
)>,
|
||||
native_particles: &'w mut Vec<lan_particles::QueuedParticle>,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -1666,8 +1667,8 @@ struct DragView {
|
||||
fn equipment_modifier_applies(group: &str, slot: &str) -> bool {
|
||||
group == slot
|
||||
|| group == "any"
|
||||
|| (group == "armor" && slot != "mainhand")
|
||||
|| (group == "hand" && slot == "mainhand")
|
||||
|| (group == "armor" && slot != "mainhand" && slot != "offhand")
|
||||
|| (group == "hand" && (slot == "mainhand" || slot == "offhand"))
|
||||
}
|
||||
/// Ctrl+Q reads the physical keyboard, independently of sprint key mappings.
|
||||
/// A chunk whose ticking future can complete (`ChunkMap.prepareTickingChunk`):
|
||||
@@ -1723,6 +1724,12 @@ struct Game<'a> {
|
||||
pending_entry: Option<PendingWorldEntry>,
|
||||
world_loader: Option<world_loading::WorldLoader>,
|
||||
pending_pack_reload: bool,
|
||||
/// When `resourcepacks/` was last looked at for the open pack screen, and what it held
|
||||
/// (`watch_pack_folder`).
|
||||
pack_folder: Option<(
|
||||
std::time::Instant,
|
||||
Vec<(String, u64, Option<std::time::SystemTime>)>,
|
||||
)>,
|
||||
/// A pack reload staged on a worker (`LoadingOverlay`): the frame draws
|
||||
/// its progress until `drive_pack_reload` applies it.
|
||||
pack_reload: Option<packs::PackReload>,
|
||||
@@ -1823,6 +1830,10 @@ struct Game<'a> {
|
||||
/// This view's corner in its shared window; cursor warps include the offset.
|
||||
view_at: [u32; 2],
|
||||
focused: bool,
|
||||
/// Whether the window currently accepts input method events.
|
||||
ime_allowed: bool,
|
||||
/// The candidate window's area last given to the window, in pixels.
|
||||
ime_area: Option<[i32; 4]>,
|
||||
/// When the window lost focus, while it is still without
|
||||
/// (`Minecraft.lastActiveTime`: pausing waits 500 ms).
|
||||
focus_lost: Option<Instant>,
|
||||
@@ -1896,6 +1907,11 @@ struct Game<'a> {
|
||||
session_lock: Option<horizon_client::save::SessionLock>,
|
||||
audio: Audio,
|
||||
music: horizon_client::music::MusicManager,
|
||||
/// `ToastManager.nowPlayingToast`: there while Music Toast is not Never.
|
||||
now_playing: Option<horizon_client::toasts::NowPlayingToast>,
|
||||
/// The Music Toast option the toast was last set up for.
|
||||
music_toast_state: u8,
|
||||
music_notes: horizon_client::toasts::MusicNotes,
|
||||
ambience: horizon_client::ambience::Ambience,
|
||||
/// The sound engine was told the game paused (pauseAllExcept).
|
||||
sound_paused: bool,
|
||||
@@ -2016,6 +2032,8 @@ struct Game<'a> {
|
||||
/// the attack strength tickers the hand reads.
|
||||
clicks: horizon_client::first_person::Clicks,
|
||||
attack_clicks: u32,
|
||||
/// Use clicks since the last tick; `handleKeybinds` consumes them after the attack clicks.
|
||||
use_clicks: u32,
|
||||
hand_strength: horizon_client::attack_strength::AttackStrength,
|
||||
settings: Settings,
|
||||
tutorial: tutorial::Tutorial,
|
||||
@@ -2246,6 +2264,8 @@ struct Game<'a> {
|
||||
world_options: Option<lan_screen::WorldOptions>,
|
||||
/// Chat Settings opened from RestrictionsScreen: Done returns to World Options.
|
||||
options_to_world_options: bool,
|
||||
/// HDR's settings opened with their key from the game: Done returns to the game.
|
||||
options_to_game: bool,
|
||||
command_block_screen: Option<command_blocks::CommandBlockScreen>,
|
||||
block_editor: Option<block_editor::BlockEditScreen>,
|
||||
/// `StructureTemplateManager.structureRepository`: the templates in memory
|
||||
@@ -2253,6 +2273,8 @@ struct Game<'a> {
|
||||
structure_templates: HashMap<String, Option<structure_blocks::TemplateEntry>>,
|
||||
/// The game tests read so far, by id (the test instance registry).
|
||||
test_defs: std::cell::RefCell<HashMap<String, Option<test_instances::TestDef>>>,
|
||||
/// `GameTestBlockHighlightRenderer.markers`: block, text, removal time.
|
||||
test_highlights: HashMap<(i32, i32, i32), (String, std::time::Instant)>,
|
||||
/// `GameTestTicker.SINGLETON`: the game tests running on this server.
|
||||
game_tests: game_tests::GameTests,
|
||||
/// KeyboardHandler's debug key state (game/debug_keys.rs), the physical
|
||||
@@ -2694,6 +2716,66 @@ impl Game<'_> {
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)))
|
||||
}
|
||||
/// `LocalPlayer.moveTowardsClosestSpace` at the body's four corners
|
||||
/// (aiStep, unless a spectator or a passenger): a corner inside a
|
||||
/// suffocating block sets a 0.1 speed towards the nearest side that is
|
||||
/// free, on that side's axis; a later corner overwrites an earlier one.
|
||||
/// Suffocating is a full collision cube that is also a full render cube,
|
||||
/// as `in_wall` has it.
|
||||
fn closest_space_nudge(&self) -> [Option<f64>; 2] {
|
||||
use horizon_core::geometry::Axis;
|
||||
let mut out = [None; 2];
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return out;
|
||||
};
|
||||
if p.spectator || self.riding.is_some() {
|
||||
return out;
|
||||
}
|
||||
let body = p.bounds();
|
||||
let (min_y, max_y) = (body.min(Axis::Y) + 1.0e-7, body.max(Axis::Y) - 1.0e-7);
|
||||
let suffocates_at = |x: i32, z: i32| {
|
||||
((min_y.floor() as i32 - 1)..=(max_y.ceil() as i32 + 1)).any(|y| {
|
||||
f64::from(y) < max_y && f64::from(y + 1) > min_y && {
|
||||
let f = self.res.facts().get(self.world.block(x, y, z));
|
||||
f.full_collision && f.solid_render
|
||||
}
|
||||
})
|
||||
};
|
||||
let side = f64::from(p.dimensions.width) * 0.35;
|
||||
let (px, pz) = (p.position.x, p.position.z);
|
||||
for (x, z) in [
|
||||
(px - side, pz + side),
|
||||
(px - side, pz - side),
|
||||
(px + side, pz - side),
|
||||
(px + side, pz + side),
|
||||
] {
|
||||
let (bx, bz) = (x.floor() as i32, z.floor() as i32);
|
||||
if !suffocates_at(bx, bz) {
|
||||
continue;
|
||||
}
|
||||
let (xd, zd) = (x - f64::from(bx), z - f64::from(bz));
|
||||
// WEST, EAST, NORTH, SOUTH: (axis, step, distance to that edge).
|
||||
let mut closest = 1.797_693_134_862_315_7e308; // Double.MAX_VALUE
|
||||
let mut chosen = None;
|
||||
for (axis, step, distance) in
|
||||
[(0, -1, xd), (0, 1, 1.0 - xd), (1, -1, zd), (1, 1, 1.0 - zd)]
|
||||
{
|
||||
let (nx, nz) = if axis == 0 {
|
||||
(bx + step, bz)
|
||||
} else {
|
||||
(bx, bz + step)
|
||||
};
|
||||
if distance < closest && !suffocates_at(nx, nz) {
|
||||
closest = distance;
|
||||
chosen = Some((axis, step));
|
||||
}
|
||||
}
|
||||
if let Some((axis, step)) = chosen {
|
||||
out[axis] = Some(0.1 * f64::from(step));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// What the window decides for its player's body every tick, in frame and
|
||||
/// on a server alike: the look (Player.travel steers sprint-swimming by
|
||||
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
|
||||
@@ -2725,9 +2807,11 @@ impl Game<'_> {
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
|
||||
let freeze_immune = self.wears_freeze_immune();
|
||||
let burn_immune = self.wears_burn_from_stepping_immunity();
|
||||
let nudge = self.closest_space_nudge();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
player.nudge = nudge;
|
||||
player.yaw = self.yaw;
|
||||
player.pitch = self.pitch;
|
||||
player.auto_jump_enabled = self.settings.auto_jump;
|
||||
@@ -3558,9 +3642,9 @@ impl Game<'_> {
|
||||
self.tick_chunks.recycle_added(added);
|
||||
}
|
||||
/// `DistanceManager`'s tickets of the level in place, from every player in
|
||||
/// it (the local player, LAN guests, a server's players) and its forced
|
||||
/// chunks. A game with neither a server nor a player (the headless
|
||||
/// fixtures) ticks everywhere.
|
||||
/// it (the local player, LAN guests, a server's players), its forced
|
||||
/// chunks and its portal tickets. A game with neither a server nor a
|
||||
/// player (the headless fixtures) ticks everywhere.
|
||||
fn update_chunk_tickets(&mut self) {
|
||||
let mut players: Vec<(i32, i32)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
@@ -3581,6 +3665,8 @@ impl Game<'_> {
|
||||
horizon_client::ticket_levels::Sources {
|
||||
players,
|
||||
forced: self.streaming.forced.iter().copied().collect(),
|
||||
// Filled from the `PORTAL` tickets by `update`.
|
||||
portal: Vec::new(),
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.simulation_distance.max(2),
|
||||
}
|
||||
@@ -3607,6 +3693,8 @@ impl Game<'_> {
|
||||
/// ServerLevel.tick block phases: scheduled ticks, random ticks in the
|
||||
/// chunks the tickets simulate, block events and moving pistons.
|
||||
fn tick_blocks(&mut self) -> Result<()> {
|
||||
// ServerChunkCache.tick: the tickets that time out.
|
||||
self.tickets.tick_portal();
|
||||
self.sync_tick_chunks();
|
||||
self.start_ticking_chunks();
|
||||
let levels = self.tickets.levels();
|
||||
@@ -5987,6 +6075,14 @@ impl Game<'_> {
|
||||
self.tick_tutorial();
|
||||
if self.screen == Screen::Creative {
|
||||
self.refresh_creative_tabs();
|
||||
// CreativeModeInventoryScreen.containerTick: a player without
|
||||
// infinite materials gets the survival inventory instead.
|
||||
if !self.creative {
|
||||
if let Some(c) = self.carried.take() {
|
||||
let _ = self.give(c);
|
||||
}
|
||||
self.screen = Screen::Inventory;
|
||||
}
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
return self.tick_remote();
|
||||
@@ -6392,6 +6488,7 @@ impl Game<'_> {
|
||||
self.share_spawn_conditions();
|
||||
self.update_chunk_tickets();
|
||||
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
|
||||
self.tick_sign_editor();
|
||||
// Player.tick's ItemCooldowns.tick for the window's player (a
|
||||
// server's players count theirs down in `tick_guests`).
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
@@ -6648,6 +6745,31 @@ impl Game<'_> {
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
}
|
||||
/// `KeyMapping.setAll`: every key physically held is held again, as the
|
||||
/// mapping it is bound to, so a key pressed or kept down behind a screen
|
||||
/// counts once the game has the keyboard back. A sneak or sprint that
|
||||
/// toggles keeps its toggle state (`shouldSetOnIngameFocus`).
|
||||
fn restore_held_keys(&mut self) {
|
||||
let held: Vec<KeyCode> = self.physical_keys.iter().copied().collect();
|
||||
let toggles = [self.settings.toggle_crouch, self.settings.toggle_sprint];
|
||||
for key in held {
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(slot_key @ (KeyCode::ShiftLeft | KeyCode::ControlLeft)) => {
|
||||
let slot = usize::from(slot_key == KeyCode::ControlLeft);
|
||||
if !toggles[slot] {
|
||||
self.toggles.0[slot].set_down(true, false);
|
||||
self.sync_toggle(slot);
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
self.keys.insert(other);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`. Both put the pointer at
|
||||
/// the window's centre, so a screen opened from gameplay (inventory,
|
||||
/// chest, pause, chat, sign, book, death) starts with the cursor in the
|
||||
@@ -6683,6 +6805,11 @@ impl Game<'_> {
|
||||
self.captured = false;
|
||||
return;
|
||||
}
|
||||
if capture && !self.captured && !cfg!(target_os = "macos") {
|
||||
// MouseHandler.grabMouse: KeyMapping.setAll unless
|
||||
// InputQuirks.RESTORE_KEY_STATE_AFTER_MOUSE_GRAB is off (macOS).
|
||||
self.restore_held_keys();
|
||||
}
|
||||
let Some(window) = &self.window else { return };
|
||||
let size = self.renderer.as_ref().map_or_else(
|
||||
|| window.inner_size().into(),
|
||||
@@ -6978,6 +7105,7 @@ impl Game<'_> {
|
||||
self.draw_toasts(&mut batch, false);
|
||||
self.draw_tutorial_toasts(&mut batch, false);
|
||||
self.draw_pack_toasts(&mut batch, false);
|
||||
self.draw_music_toast(&mut batch, false);
|
||||
return batch;
|
||||
}
|
||||
let atlas_ptr: *const HudAtlas = &self.gui.atlas;
|
||||
@@ -6986,11 +7114,13 @@ impl Game<'_> {
|
||||
// download while the connection screen shows.
|
||||
if self.remote_overlay(&mut batch) {
|
||||
self.draw_pack_toasts(&mut batch, false);
|
||||
self.draw_music_toast(&mut batch, false);
|
||||
return batch;
|
||||
}
|
||||
if let Some(reason) = self.loading_reason() {
|
||||
self.draw_level_loading(&mut batch, reason);
|
||||
self.draw_pack_toasts(&mut batch, false);
|
||||
self.draw_music_toast(&mut batch, false);
|
||||
return batch;
|
||||
}
|
||||
// Hud.extractRenderState: F1 (keyToggleGui) hides camera overlays, the
|
||||
@@ -7243,6 +7373,7 @@ impl Game<'_> {
|
||||
self.draw_toasts(&mut batch, !show_hud);
|
||||
self.draw_tutorial_toasts(&mut batch, !show_hud);
|
||||
self.draw_pack_toasts(&mut batch, !show_hud);
|
||||
self.draw_music_toast(&mut batch, !show_hud);
|
||||
if self.server_packs.prompt.is_none() {
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
@@ -7404,6 +7535,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn slot_at(&self, batch: &HudBatch, x: f32, y: f32) -> Option<usize> {
|
||||
if self.book_covers_menu(batch.width) {
|
||||
return None;
|
||||
}
|
||||
let (ox, oy) = self.inventory_origin(batch);
|
||||
self.slots().into_iter().find(|&i| {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
@@ -7412,6 +7546,40 @@ impl Game<'_> {
|
||||
x >= ox + sx - 1.0 && x < ox + sx + 17.0 && y >= oy + sy - 1.0 && y < oy + sy + 17.0
|
||||
})
|
||||
}
|
||||
/// `AbstractContainerScreen.extractSlotHighlightBack` and
|
||||
/// `AbstractContainerScreen.extractSlotHighlightFront`: the 24 x 24
|
||||
/// `container/slot_highlight_back` sprite behind the slot at `x`, `y` (its
|
||||
/// 16 x 16 item box), the `_front` one over its item.
|
||||
fn draw_slot_highlight(&self, batch: &mut HudBatch, front: bool, x: f32, y: f32) {
|
||||
let sprite = if front {
|
||||
"gui/sprites/container/slot_highlight_front"
|
||||
} else {
|
||||
"gui/sprites/container/slot_highlight_back"
|
||||
};
|
||||
horizon_client::ui::blit_sprite(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
sprite,
|
||||
x - 4.0,
|
||||
y - 4.0,
|
||||
24.0,
|
||||
24.0,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
/// The highlight of the slot under the cursor, for the screens whose
|
||||
/// slots all sit in `slot_at`'s grid. `Slot.isHighlightable` is false
|
||||
/// for the crafter's result (`NonInteractiveResultSlot`).
|
||||
fn draw_hovered_slot_highlight(&self, batch: &mut HudBatch, front: bool, ox: f32, oy: f32) {
|
||||
let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) else {
|
||||
return;
|
||||
};
|
||||
if slot == 309 && matches!(self.container_kind(), Some(BlockEntity::Crafter(_))) {
|
||||
return;
|
||||
}
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
self.draw_slot_highlight(batch, front, ox + sx, oy + sy);
|
||||
}
|
||||
fn slot_mut(&mut self, i: usize) -> &mut Option<ItemStack> {
|
||||
if i >= mount_menu::SADDLE {
|
||||
return self.mount_slot_mut(i);
|
||||
@@ -7645,25 +7813,22 @@ impl Game<'_> {
|
||||
if self.container.is_none() && self.craft_size != 3 && !self.mount_active() {
|
||||
self.draw_inventory_player(batch, ox, oy);
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, false, ox, oy);
|
||||
self.draw_inventory_slots(batch, ox, oy);
|
||||
self.draw_hovered_slot_highlight(batch, true, ox, oy);
|
||||
self.draw_sort_buttons(batch, ox, oy);
|
||||
self.draw_recipe_book(batch);
|
||||
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
16.0,
|
||||
16.0,
|
||||
rgba(255, 255, 255, 128),
|
||||
);
|
||||
}
|
||||
let effect_tip = if self.container.is_none() && self.craft_size == 2 && !self.mount_active()
|
||||
{
|
||||
self.draw_inventory_effects(batch, (ox, oy), 176)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.draw_carried(batch);
|
||||
self.draw_slot_tooltip(batch);
|
||||
self.draw_native_screen_tooltip(batch);
|
||||
if self.container.is_none() && self.craft_size == 2 && !self.mount_active() {
|
||||
self.draw_inventory_effects(batch, (ox, oy), 176);
|
||||
if let Some(lines) = effect_tip {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
@@ -9130,6 +9295,7 @@ impl Game<'_> {
|
||||
let mut see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
see_through.clear();
|
||||
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.test_instance_marker_geometry(camera, &mut out, &mut see_through);
|
||||
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.see_through_vertices = see_through;
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
@@ -9644,7 +9810,15 @@ impl Game<'_> {
|
||||
// the offset is the one from the player to the particle.
|
||||
let origin = from.unwrap_or(at);
|
||||
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
|
||||
self.lan_send_particles(self.dim.id(), &arc, None, at, spread, 0.0, 0);
|
||||
self.lan_send_particles(lan_particles::QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options: arc,
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed: 0.0,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
|
||||
/// and can't be hit critically): knockback when sprinting at full
|
||||
@@ -9873,15 +10047,15 @@ impl Game<'_> {
|
||||
(actual * 0.5) as u32,
|
||||
);
|
||||
// The players a server serves are sent them too.
|
||||
self.lan_send_particles(
|
||||
self.dim.id(),
|
||||
&indicator,
|
||||
None,
|
||||
[feet[0], feet[1] + height * 0.5, feet[2]],
|
||||
[0.1, 0.0, 0.1],
|
||||
0.2,
|
||||
(actual * 0.5) as u32 as i32,
|
||||
);
|
||||
self.lan_send_particles(lan_particles::QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options: indicator,
|
||||
state: None,
|
||||
at: [feet[0], feet[1] + height * 0.5, feet[2]],
|
||||
spread: [0.1, 0.0, 0.1],
|
||||
speed: 0.2,
|
||||
count: (actual * 0.5) as u32 as i32,
|
||||
});
|
||||
}
|
||||
self.last_hurt_by_player
|
||||
.insert(self.mobs[index].id, self.lan_acting);
|
||||
@@ -10000,6 +10174,13 @@ impl Game<'_> {
|
||||
let Some(r) = atlas.sprites.get(name).copied() else {
|
||||
return;
|
||||
};
|
||||
// How brightly HDR output lets the body shine (mesher::hdr_tier):
|
||||
// the moon at the low tier, not the sun's.
|
||||
let tier = if moon {
|
||||
horizon_client::mesher::LOW
|
||||
} else {
|
||||
horizon_client::mesher::VERY_BRIGHT
|
||||
};
|
||||
let m = frame(angle);
|
||||
let corners = [(-size, -size), (size, -size), (size, size), (-size, size)];
|
||||
// buildCelestialQuad (sun) vs buildMoonPhases: the moon's UVs run
|
||||
@@ -10015,7 +10196,7 @@ impl Game<'_> {
|
||||
normal: [0; 4],
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
layer: 2 | u32::from(tier) << 16,
|
||||
color: body_color,
|
||||
light: 0,
|
||||
|
||||
@@ -10131,10 +10312,13 @@ impl Game<'_> {
|
||||
if self.window.is_some() {
|
||||
self.perf.world_opening();
|
||||
if self.title.is_none() {
|
||||
self.title = Some(Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&self.options.resources,
|
||||
));
|
||||
self.title = Some(
|
||||
Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&self.options.resources,
|
||||
)
|
||||
.with_user(|| self.options.player_name()),
|
||||
);
|
||||
}
|
||||
self.world_loader = Some(world_loading::WorldLoader::start(
|
||||
request,
|
||||
@@ -10642,10 +10826,13 @@ impl Game<'_> {
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.clear_sections(self.world.layout());
|
||||
}
|
||||
self.title = Some(Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&self.options.resources,
|
||||
));
|
||||
self.title = Some(
|
||||
Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&self.options.resources,
|
||||
)
|
||||
.with_user(|| self.options.player_name()),
|
||||
);
|
||||
if let Some(title) = &mut self.title {
|
||||
title.last_server = self.settings.file.string("lastServer");
|
||||
title.skip_multiplayer_warning = self.settings.file.bool("skipMultiplayerWarning");
|
||||
@@ -10698,6 +10885,7 @@ impl Game<'_> {
|
||||
.map(|(s, n)| (s.as_ref(), n))
|
||||
.collect();
|
||||
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
|
||||
equipped.push((self.offhand.as_ref(), "offhand"));
|
||||
for (stack, slot) in equipped {
|
||||
let Some(info) = stack.and_then(|s| world_items!(self).get(&s.item)) else {
|
||||
continue;
|
||||
@@ -11070,16 +11258,19 @@ impl Game<'_> {
|
||||
ItemStack::new("minecraft:hanging_roots", 1),
|
||||
);
|
||||
}
|
||||
self.audio.play(
|
||||
&sound,
|
||||
Some([
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
// `Level.playSound(player, ...)`: the actor's client already played
|
||||
// the strip/scrape/till sound in `predict_item_on_block` (#4681).
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if self.lan_acting.is_some() {
|
||||
self.audio.play_unshared(&sound, Some(at), 1.0, 1.0);
|
||||
self.guest_player_sounds.push((sound.clone(), at, 1.0, 1.0));
|
||||
} else {
|
||||
self.audio.play(&sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
// ItemStack.useOn: a use that acted counts as one of the item.
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.damage_held(damage);
|
||||
@@ -11297,6 +11488,24 @@ impl Game<'_> {
|
||||
if !emptied {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
// `BucketItem.emptyContents` uses `playSound(player, …)`: the
|
||||
// actor's client already played the empty / evaporate sound from
|
||||
// its DryLevel prediction (#4684).
|
||||
if self.lan_acting.is_some()
|
||||
&& let Some(capture) = self.audio.capture.as_mut()
|
||||
{
|
||||
let want = sound.trim_start_matches("minecraft:");
|
||||
let mut keep = Vec::with_capacity(capture.len());
|
||||
for entry in capture.drain(..) {
|
||||
let name = entry.0.trim_start_matches("minecraft:");
|
||||
if name == want || name == "block.fire.extinguish" {
|
||||
self.guest_player_sounds.push(entry);
|
||||
} else {
|
||||
keep.push(entry);
|
||||
}
|
||||
}
|
||||
*capture = keep;
|
||||
}
|
||||
if let Some(kind) = mob {
|
||||
if let Some(id) = self.spawn_mob_at(kind, (target[0], target[1], target[2]), true) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
@@ -12527,29 +12736,17 @@ impl Game<'_> {
|
||||
20.0,
|
||||
0.0625,
|
||||
);
|
||||
let trash = Self::creative_trash_hovered(batch, self.cursor);
|
||||
let (tx, ty) = Creative::TRASH_POSITION;
|
||||
if trash {
|
||||
self.draw_slot_highlight(batch, false, ox + tx, oy + ty);
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, false, ox, oy);
|
||||
self.draw_inventory_slots(batch, ox, oy);
|
||||
if Self::creative_trash_hovered(batch, self.cursor) {
|
||||
let (x, y) = Creative::TRASH_POSITION;
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
ox + x,
|
||||
oy + y,
|
||||
16.0,
|
||||
16.0,
|
||||
rgba(255, 255, 255, 128),
|
||||
);
|
||||
}
|
||||
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
|
||||
let (x, y) = self.slot_position(slot);
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
ox + x,
|
||||
oy + y,
|
||||
16.0,
|
||||
16.0,
|
||||
rgba(255, 255, 255, 128),
|
||||
);
|
||||
if trash {
|
||||
self.draw_slot_highlight(batch, true, ox + tx, oy + ty);
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, true, ox, oy);
|
||||
} else {
|
||||
let items = self.creative_screen.items(&self.pack.names);
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
@@ -12559,13 +12756,16 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
|
||||
self.draw_item(batch, stack, x, y);
|
||||
if self.cursor.0 >= x
|
||||
let over = self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
&& self.cursor.1 < y + 16.0;
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
self.draw_item(batch, stack, x, y);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, true, x, y);
|
||||
hovered = Some((stack.clone(), true));
|
||||
}
|
||||
}
|
||||
@@ -12573,13 +12773,16 @@ impl Game<'_> {
|
||||
let drag = self.drag_view();
|
||||
for c in 0..9 {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
|
||||
if self.cursor.0 >= x
|
||||
let over = self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
&& self.cursor.1 < y + 16.0;
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, true, x, y);
|
||||
hovered = self.inventory[c].clone().map(|s| (s, false));
|
||||
}
|
||||
}
|
||||
@@ -12596,6 +12799,7 @@ impl Game<'_> {
|
||||
oy + Creative::SCROLL_Y + self.creative_screen.scroller_offset(),
|
||||
);
|
||||
}
|
||||
let effect_tip = self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
for t in &tabs {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
if self.cursor.0 >= ox + tx
|
||||
@@ -12618,9 +12822,9 @@ impl Game<'_> {
|
||||
// `inventory.binSlot`, not an English literal.
|
||||
let destroy = self.translate("inventory.binSlot", "Destroy Item");
|
||||
self.tooltip(batch, &destroy, self.cursor.0, self.cursor.1);
|
||||
} else if self.carried.is_none()
|
||||
&& let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
|
||||
} else if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
|
||||
&& let Some(stack) = self.slot_view(slot)
|
||||
&& self.tooltip_with_carried(&stack)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&stack, false);
|
||||
self.draw_stack_tooltip_with(
|
||||
@@ -12632,14 +12836,16 @@ impl Game<'_> {
|
||||
tabs,
|
||||
);
|
||||
}
|
||||
} else if self.carried.is_none()
|
||||
&& let Some((item, grid)) = hovered
|
||||
} else if let Some((item, grid)) = hovered
|
||||
&& self.tooltip_with_carried(&item)
|
||||
{
|
||||
let tabs = self.creative_tooltip_tabs(&item, grid);
|
||||
// A `CustomCreativeSlot`: TooltipFlag.asCreative.
|
||||
self.draw_stack_tooltip_with(batch, &item, grid, self.cursor.0, self.cursor.1, tabs);
|
||||
}
|
||||
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
|
||||
if let Some(lines) = effect_tip {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
fn creative_trash_hovered(batch: &HudBatch, cursor: (f32, f32)) -> bool {
|
||||
let (ox, oy) = Self::creative_origin(batch);
|
||||
@@ -12845,7 +13051,11 @@ impl Game<'_> {
|
||||
self.save_warden_tracker();
|
||||
self.sync_level();
|
||||
self.store_owner();
|
||||
self.level.write(&self.world_root)?;
|
||||
if self.options.dedicated.is_some() {
|
||||
self.level.write_without_player(&self.world_root)?;
|
||||
} else {
|
||||
self.level.write(&self.world_root)?;
|
||||
}
|
||||
self.save_world_state();
|
||||
self.message = Some(("Saved the game".into(), Instant::now()));
|
||||
Ok(())
|
||||
@@ -13187,6 +13397,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
self.flush_ime(&mut hud);
|
||||
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
|
||||
let title = self.title.is_some()
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|
||||
@@ -13279,7 +13490,12 @@ impl Game<'_> {
|
||||
let screen = if title {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.screen_quads(alpha as f32, size)
|
||||
let scale = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:scale").ok())
|
||||
.unwrap_or(1.0) as f32;
|
||||
self.screen_quads(alpha as f32, size, scale)
|
||||
};
|
||||
if !title {
|
||||
self.end_geometry(camera, &mut entities);
|
||||
@@ -13421,6 +13637,21 @@ impl Game<'_> {
|
||||
effects: &requested_post_effects,
|
||||
root: &self.options.resources,
|
||||
menu_blur_radius: self.settings.menu_blur as f32,
|
||||
// GlobalSettingsUniform.update.
|
||||
globals: horizon_client::post_chain::Globals {
|
||||
camera_block_pos: [
|
||||
camera.x.floor() as i32,
|
||||
camera.y.floor() as i32,
|
||||
camera.z.floor() as i32,
|
||||
],
|
||||
camera_offset: [
|
||||
(camera.x.floor() - camera.x) as f32,
|
||||
(camera.y.floor() - camera.y) as f32,
|
||||
(camera.z.floor() - camera.z) as f32,
|
||||
],
|
||||
game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0,
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
chunk_fade: self.settings.chunk_fade,
|
||||
};
|
||||
@@ -13582,7 +13813,7 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.sort_key_press(key) {
|
||||
if self.sort_key_press(key) || self.hdr_key_press(key) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.remote.is_some() && self.remote_key(key)? {
|
||||
@@ -13782,10 +14013,10 @@ impl Game<'_> {
|
||||
.button(3, pressed, false, self.settings.toggle_use);
|
||||
self.right = self.toggles.is_down(3);
|
||||
// handleKeybinds consumes use clicks without acting while an
|
||||
// item is in use.
|
||||
// item is in use. Minecraft.tick runs them after the attack
|
||||
// clicks, so a place and a break in one tick replace the block.
|
||||
if pressed && self.using.is_none() {
|
||||
self.use_with_hand()?;
|
||||
self.use_delay = 4;
|
||||
self.use_clicks += 1;
|
||||
}
|
||||
}
|
||||
MouseButton::Middle if pressed => {
|
||||
@@ -13870,8 +14101,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.lod_source_attempted = true;
|
||||
let dim = self.dim;
|
||||
let source = match &self.integrated {
|
||||
Some(server) => server.call(|game| game.streaming.lod_source()),
|
||||
Some(server) => server.call(move |game| game.lod_source_in(dim)).flatten(),
|
||||
None => Some(self.streaming.lod_source()),
|
||||
};
|
||||
if let Some(source) = source {
|
||||
@@ -13964,7 +14196,8 @@ impl Game<'_> {
|
||||
let g = &self.settings.horizon;
|
||||
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
|
||||
// The meshes, once copied, are freed by a worker (`Streaming::free`).
|
||||
if !budgeted && self.pending_uploads.is_empty() {
|
||||
if !budgeted && self.pending_uploads.is_empty() && uploads.len() <= AUTO_UPLOADS_AT_ONCE
|
||||
{
|
||||
horizon_client::spans::time("upload", || {
|
||||
for (key, mesh) in &uploads {
|
||||
renderer.upload(*key, mesh);
|
||||
@@ -14003,7 +14236,15 @@ impl Game<'_> {
|
||||
(0, 0)
|
||||
};
|
||||
let (mut count, mut bytes) = (0, 0u64);
|
||||
// Without the player's budgets, uploads stop once they took
|
||||
// AUTO_UPLOAD_TIME of this frame (at least one goes): a burst of
|
||||
// finished meshes spreads over frames, nearest first.
|
||||
let auto = !budgeted;
|
||||
let started = Instant::now();
|
||||
while let Some((_, mesh)) = pending.last() {
|
||||
if auto && count > 0 && started.elapsed() >= AUTO_UPLOAD_TIME {
|
||||
break;
|
||||
}
|
||||
let size = ((mesh.solid.len() + mesh.translucent.len())
|
||||
* std::mem::size_of::<horizon_client::mesher::TerrainVertex>())
|
||||
as u64;
|
||||
@@ -14106,6 +14347,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
self.window = Some(window);
|
||||
self.ime_allowed = false;
|
||||
self.ime_area = None;
|
||||
self.apply_settings();
|
||||
self.previous = Instant::now();
|
||||
}
|
||||
@@ -14319,6 +14562,14 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
self.begin_closing_save(true)?;
|
||||
}
|
||||
// Input method composition: the committed text types like a key's text.
|
||||
WindowEvent::Ime(winit::event::Ime::Commit(text)) => self.ime_commit(&text),
|
||||
WindowEvent::Ime(winit::event::Ime::Preedit(text, cursor)) => {
|
||||
self.ime_preedit(&text, cursor)
|
||||
}
|
||||
WindowEvent::Ime(winit::event::Ime::Disabled) => {
|
||||
horizon_client::preedit::set_preedit(None)
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
if let Some(r) = &mut self.renderer {
|
||||
r.resize(size.width, size.height);
|
||||
@@ -14330,6 +14581,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.focus_lost = (!focused).then(Instant::now);
|
||||
if !focused && self.control.is_none() {
|
||||
self.keys.clear();
|
||||
// Latched sneak/sprint live in `toggles`, not in the
|
||||
// physical keys: put them back so a short alt-tab
|
||||
// (no pause screen) does not drop them.
|
||||
self.sync_toggle(0);
|
||||
self.sync_toggle(1);
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
@@ -14360,6 +14616,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
if self.screen == Screen::Inventory {
|
||||
self.station_drag();
|
||||
self.recipe_book_drag();
|
||||
}
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative) {
|
||||
self.inventory_drag_move();
|
||||
@@ -14585,7 +14842,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
// Neither edge of a bound button is an ordinary slot click.
|
||||
return Ok(());
|
||||
}
|
||||
if pressed && self.sort_mouse_press(button) {
|
||||
if pressed && (self.sort_mouse_press(button) || self.hdr_mouse_press(button)) {
|
||||
return Ok(());
|
||||
}
|
||||
match self.screen {
|
||||
@@ -14642,6 +14899,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
Screen::Inventory => {
|
||||
self.inventory_mouse_release(button == MouseButton::Right);
|
||||
self.station_release();
|
||||
self.recipe_book_release();
|
||||
}
|
||||
Screen::Chat if pressed && button == MouseButton::Left => self.chat_click(),
|
||||
Screen::Creative if pressed => {
|
||||
@@ -14951,6 +15209,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.observe_fullscreen();
|
||||
self.sync_ime();
|
||||
let now = Instant::now();
|
||||
let frame_cpu = self
|
||||
.options
|
||||
@@ -15318,6 +15577,7 @@ impl<'a> Game<'a> {
|
||||
pending_entry: None,
|
||||
world_loader: None,
|
||||
pending_pack_reload: false,
|
||||
pack_folder: None,
|
||||
pack_reload: None,
|
||||
server_packs: Default::default(),
|
||||
link_prompt: None,
|
||||
@@ -15389,6 +15649,8 @@ impl<'a> Game<'a> {
|
||||
keyboard_mouse: true,
|
||||
view_at: [0, 0],
|
||||
focused: true,
|
||||
ime_allowed: false,
|
||||
ime_area: None,
|
||||
focus_lost: None,
|
||||
modifiers: winit::keyboard::ModifiersState::empty(),
|
||||
fullscreen: horizon_client::window::FullscreenState::new(false),
|
||||
@@ -15437,6 +15699,10 @@ impl<'a> Game<'a> {
|
||||
);
|
||||
music
|
||||
},
|
||||
// ToastManager.initializeMusicToast: Pause and Pause and Toast make the toast.
|
||||
now_playing: (settings.file.int("musicToast").clamp(0, 2) != 0).then(Default::default),
|
||||
music_toast_state: settings.file.int("musicToast").clamp(0, 2) as u8,
|
||||
music_notes: Default::default(),
|
||||
ambience: Default::default(),
|
||||
sound_paused: false,
|
||||
cart_sounds: HashSet::new(),
|
||||
@@ -15504,6 +15770,7 @@ impl<'a> Game<'a> {
|
||||
hands_replaced: [false; 2],
|
||||
clicks: Default::default(),
|
||||
attack_clicks: 0,
|
||||
use_clicks: 0,
|
||||
hand_strength: Default::default(),
|
||||
settings,
|
||||
tutorial,
|
||||
@@ -15642,10 +15909,12 @@ impl<'a> Game<'a> {
|
||||
serving: None,
|
||||
world_options: None,
|
||||
options_to_world_options: false,
|
||||
options_to_game: false,
|
||||
command_block_screen: None,
|
||||
block_editor: None,
|
||||
structure_templates: HashMap::new(),
|
||||
test_defs: std::cell::RefCell::new(HashMap::new()),
|
||||
test_highlights: HashMap::new(),
|
||||
game_tests: game_tests::GameTests::default(),
|
||||
friends: None,
|
||||
debug_keys: Default::default(),
|
||||
@@ -15830,10 +16099,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.perf.world_opening();
|
||||
if self.title.is_none() {
|
||||
self.title = Some(Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&self.options.resources,
|
||||
));
|
||||
self.title = Some(
|
||||
Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&self.options.resources,
|
||||
)
|
||||
.with_user(|| self.options.player_name()),
|
||||
);
|
||||
}
|
||||
self.pending_entry = Some(entry);
|
||||
log::info!("Horizon: world entry waiting for world resources");
|
||||
@@ -16735,10 +17007,13 @@ fn start_game<'a>(
|
||||
} else {
|
||||
// Start after the window is handed over, so --world uses the
|
||||
// same responsive initialization path as the title screen.
|
||||
game.title = Some(Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&game.options.resources,
|
||||
));
|
||||
game.title = Some(
|
||||
Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&game.options.resources,
|
||||
)
|
||||
.with_user(|| game.options.player_name()),
|
||||
);
|
||||
game.pending_entry = Some(PendingWorldEntry::Local {
|
||||
folder,
|
||||
name: None,
|
||||
@@ -16753,7 +17028,8 @@ fn start_game<'a>(
|
||||
let mut title = Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
&game.options.resources,
|
||||
);
|
||||
)
|
||||
.with_user(|| game.options.player_name());
|
||||
title.last_server = game.settings.file.string("lastServer");
|
||||
title.skip_multiplayer_warning = game.settings.file.bool("skipMultiplayerWarning");
|
||||
// Gui.addInitialScreens: the accessibility onboarding comes first.
|
||||
@@ -17516,7 +17792,7 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_client::container_screen::{Button, Clicks, Release, Target};
|
||||
|
||||
/// Held items apply their main-hand modifiers to the player (#4118).
|
||||
/// Held items apply their main-hand and off-hand modifiers to the player (#4118, #4777).
|
||||
#[test]
|
||||
fn equipment_modifiers_follow_their_slot_group() {
|
||||
assert!(equipment_modifier_applies("mainhand", "mainhand"));
|
||||
@@ -17526,6 +17802,12 @@ mod tests {
|
||||
assert!(!equipment_modifier_applies("mainhand", "head"));
|
||||
assert!(equipment_modifier_applies("armor", "chest"));
|
||||
assert!(!equipment_modifier_applies("hand", "feet"));
|
||||
assert!(equipment_modifier_applies("offhand", "offhand"));
|
||||
assert!(equipment_modifier_applies("hand", "offhand"));
|
||||
assert!(equipment_modifier_applies("any", "offhand"));
|
||||
assert!(!equipment_modifier_applies("armor", "offhand"));
|
||||
assert!(!equipment_modifier_applies("mainhand", "offhand"));
|
||||
assert!(!equipment_modifier_applies("offhand", "mainhand"));
|
||||
}
|
||||
use horizon_client::keymapping::{KeyAction, SPRINT, ToggleKeys};
|
||||
|
||||
|
||||
@@ -1711,19 +1711,43 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
|
||||
let (key, color) = match frame {
|
||||
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
|
||||
Frame::Goal => ("advancements.toast.goal", 0xFF00_FFFF),
|
||||
Frame::Task => ("advancements.toast.task", 0xFFFF_FF00),
|
||||
Frame::Goal => ("advancements.toast.goal", 0xFFFF_FF00),
|
||||
Frame::Challenge => ("advancements.toast.challenge", 0xFFFF_88FF),
|
||||
};
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::translate(key, &[]),
|
||||
x + 30.0,
|
||||
y + 7.0,
|
||||
color,
|
||||
false,
|
||||
);
|
||||
batch.text_styled(atlas, &title, x + 30.0, y + 18.0, u32::MAX, false);
|
||||
// AdvancementToast.extractRenderState: a title of one line sits under
|
||||
// the frame's label; a longer one is wrapped at 125, the label fading
|
||||
// out first (to 1.5 s) and the lines fading in after it over 0.3 s.
|
||||
let lines = atlas.font.split(&title.resolve_legacy(), 125.0);
|
||||
let label = Text::translate(key, &[]);
|
||||
let rgb = color & 0x00FF_FFFF;
|
||||
if let [line] = lines.as_slice() {
|
||||
batch.text_styled(atlas, &label, x + 30.0, y + 7.0, color, false);
|
||||
batch.text_styled(atlas, line, x + 30.0, y + 18.0, u32::MAX, false);
|
||||
} else if fully_for < 1500 {
|
||||
let fade = ((1500 - fully_for) as f32 / 300.0).clamp(0.0, 1.0);
|
||||
let alpha = (fade * 255.0).floor() as u32;
|
||||
if alpha != 0 {
|
||||
batch.text_styled(atlas, &label, x + 30.0, y + 11.0, alpha << 24 | rgb, false);
|
||||
}
|
||||
} else {
|
||||
let fade = ((fully_for - 1500) as f32 / 300.0).clamp(0.0, 1.0);
|
||||
let alpha = (fade * 252.0).floor() as u32;
|
||||
if alpha != 0 {
|
||||
let mut line_y = y + (16 - lines.len() as i32 * 9 / 2) as f32;
|
||||
for line in &lines {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
x + 30.0,
|
||||
line_y,
|
||||
alpha << 24 | 0x00FF_FFFF,
|
||||
false,
|
||||
);
|
||||
line_y += 9.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,12 +726,6 @@ impl Game<'_> {
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let uv_of = |name: &str| sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let Some(wood) = uv_of("entity/armorstand/armorstand") else {
|
||||
return;
|
||||
};
|
||||
for a in &self.armor_stands {
|
||||
let entity_start = out.len();
|
||||
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
|
||||
@@ -764,9 +758,6 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let Some(model) = (if a.small { &assets.small } else { &assets.main }) else {
|
||||
continue;
|
||||
};
|
||||
let p = glam::DVec3::new(a.position.x, a.position.y, a.position.z) - camera;
|
||||
// setupRotations: 180 - body yaw, plus the wobble for 5 ticks after a hit.
|
||||
let wiggle = (self.world.game_time - a.last_hit) as f32 + alpha as f32;
|
||||
@@ -779,143 +770,220 @@ impl Game<'_> {
|
||||
* glam::Mat4::from_rotation_y((180.0 - a.yaw + wobble).to_radians())
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let mut poses = Poses::new(model);
|
||||
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
|
||||
apply_pose(&mut poses, &a.pose);
|
||||
// Item layers pose from the hidden arms too (`visible` only skips drawing).
|
||||
let posed = poses.clone();
|
||||
let mut hide = |name: &str| {
|
||||
if poses.has(name) {
|
||||
let pose = poses.get(name);
|
||||
pose[6] = 0.0;
|
||||
pose[7] = 0.0;
|
||||
pose[8] = 0.0;
|
||||
}
|
||||
};
|
||||
if !a.show_arms {
|
||||
hide("left_arm");
|
||||
hide("right_arm");
|
||||
}
|
||||
if a.no_base_plate {
|
||||
hide("base_plate");
|
||||
}
|
||||
let light = self.world.light(
|
||||
a.position.x.floor() as i32,
|
||||
(a.position.y + 0.5).floor() as i32,
|
||||
a.position.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
|
||||
// layers (held items, custom head, armour) are submitted all the same.
|
||||
if !a.invisible {
|
||||
self.armor_stand_vertices(a, base, packed, out);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
/// `ArmorStandRenderer.submit` for stand `a` under `base` (its
|
||||
/// `setupRotations`): the body, the hand and head items, the armour and
|
||||
/// the wings, at packed light `packed`.
|
||||
fn armor_stand_vertices(
|
||||
&self,
|
||||
a: &ArmorStand,
|
||||
base: glam::Mat4,
|
||||
packed: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let uv_of = |name: &str| sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let Some(wood) = uv_of("entity/armorstand/armorstand") else {
|
||||
return;
|
||||
};
|
||||
let Some(model) = (if a.small { &assets.small } else { &assets.main }) else {
|
||||
return;
|
||||
};
|
||||
let mut poses = Poses::new(model);
|
||||
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
|
||||
apply_pose(&mut poses, &a.pose);
|
||||
// Item layers pose from the hidden arms too (`visible` only skips drawing).
|
||||
let posed = poses.clone();
|
||||
let mut hide = |name: &str| {
|
||||
if poses.has(name) {
|
||||
let pose = poses.get(name);
|
||||
pose[6] = 0.0;
|
||||
pose[7] = 0.0;
|
||||
pose[8] = 0.0;
|
||||
}
|
||||
};
|
||||
if !a.show_arms {
|
||||
hide("left_arm");
|
||||
hide("right_arm");
|
||||
}
|
||||
if a.no_base_plate {
|
||||
hide("base_plate");
|
||||
}
|
||||
// LivingEntityRenderer.submit: an invisible stand has no body render type, but its
|
||||
// layers (held items, custom head, armour) are submitted all the same.
|
||||
if !a.invisible {
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
wood,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet.
|
||||
for (slot, layer) in [
|
||||
(5usize, "helmet"),
|
||||
(4, "chestplate"),
|
||||
(3, "leggings"),
|
||||
(2, "boots"),
|
||||
] {
|
||||
let Some(stack) = &a.equipment[slot] else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_asset.clone())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let kind = if layer == "leggings" {
|
||||
"humanoid_leggings"
|
||||
} else {
|
||||
"humanoid"
|
||||
};
|
||||
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(part) = assets.layers.get(&(layer, a.small)) else {
|
||||
continue;
|
||||
};
|
||||
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
|
||||
// when `stack.hasFoil()`, as on a living entity.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(&stack.item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.has_foil(item_glint) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
|
||||
let mut layer_poses = Poses::new(part);
|
||||
apply_pose(&mut layer_poses, &a.pose);
|
||||
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
|
||||
// too; the shader glint goes on the first layer only (renderShaderGlint).
|
||||
for (i, (texture, undyed)) in layers.iter().enumerate() {
|
||||
let Some(uv) = uv_of(texture) else {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, Some(stack));
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
part,
|
||||
base,
|
||||
&poses,
|
||||
wood,
|
||||
0xFFFF_FFFF,
|
||||
&layer_poses,
|
||||
uv,
|
||||
color,
|
||||
packed,
|
||||
0,
|
||||
if i == 0 { overlay } else { 0 },
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet.
|
||||
for (slot, layer) in [
|
||||
(5usize, "helmet"),
|
||||
(4, "chestplate"),
|
||||
(3, "leggings"),
|
||||
(2, "boots"),
|
||||
] {
|
||||
let Some(stack) = &a.equipment[slot] else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_asset.clone())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let kind = if layer == "leggings" {
|
||||
"humanoid_leggings"
|
||||
} else {
|
||||
"humanoid"
|
||||
};
|
||||
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(part) = assets.layers.get(&(layer, a.small)) else {
|
||||
continue;
|
||||
};
|
||||
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
|
||||
// when `stack.hasFoil()`, as on a living entity.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(&stack.item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.has_foil(item_glint) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
|
||||
let mut layer_poses = Poses::new(part);
|
||||
apply_pose(&mut layer_poses, &a.pose);
|
||||
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
|
||||
// too; the shader glint goes on the first layer only (renderShaderGlint).
|
||||
for (i, (texture, undyed)) in layers.iter().enumerate() {
|
||||
let Some(uv) = uv_of(texture) else {
|
||||
continue;
|
||||
};
|
||||
let color = layer_color(*undyed, Some(stack));
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
uv,
|
||||
color,
|
||||
packed,
|
||||
if i == 0 { overlay } else { 0 },
|
||||
out,
|
||||
);
|
||||
}
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(trim) = assets
|
||||
.trim_texture(&asset, kind, stack)
|
||||
.and_then(|t| uv_of(&t.sprite()))
|
||||
{
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(trim) = assets
|
||||
.trim_texture(&asset, kind, stack)
|
||||
.and_then(|t| uv_of(&t.sprite()))
|
||||
{
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// WingsLayer: ArmorStandRenderer adds it after the armour layers. A stand
|
||||
// never crouches or glides, so its wings rest folded.
|
||||
self.wings_geometry(
|
||||
a.equipment[4].as_ref().map(|s| &*s.item),
|
||||
None,
|
||||
a.small,
|
||||
base,
|
||||
horizon_client::mobs::ElytraAnimation::RESTING,
|
||||
false,
|
||||
packed,
|
||||
self.wings_overlay(a.equipment[4].as_ref()),
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
// WingsLayer: ArmorStandRenderer adds it after the armour layers. A stand
|
||||
// never crouches or glides, so its wings rest folded.
|
||||
self.wings_geometry(
|
||||
a.equipment[4].as_ref().map(|s| &*s.item),
|
||||
None,
|
||||
a.small,
|
||||
base,
|
||||
horizon_client::mobs::ElytraAnimation::RESTING,
|
||||
false,
|
||||
packed,
|
||||
self.wings_overlay(a.equipment[4].as_ref()),
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `SmithingScreen`'s armour stand: `updateArmorStandPreview` puts the
|
||||
/// result on a stand without its base plate and with its arms, turned to
|
||||
/// 210 degrees, and `extractEntityInInventory` draws it at scale 25 in
|
||||
/// the box (x0, y0) to (x1, y1), moved up 1 and tilted by `ARMOR_STAND_ANGLE`.
|
||||
pub(super) fn draw_smithing_armor_stand(
|
||||
&self,
|
||||
batch: &mut horizon_client::hud::HudBatch,
|
||||
result: Option<&ItemStack>,
|
||||
[x0, y0, x1, y1]: [f32; 4],
|
||||
) {
|
||||
let mut stand = ArmorStand::new(0, Vec3::ZERO, 210.0);
|
||||
stand.no_base_plate = true;
|
||||
stand.show_arms = true;
|
||||
if let Some(stack) = result {
|
||||
let equip = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_slot.clone());
|
||||
stand.equipment[preview_slot(equip.as_deref())] = Some(stack.clone());
|
||||
}
|
||||
// LivingEntityRenderer: setupRotations (180 - body yaw), the flip and
|
||||
// the 1.501 drop, at full light.
|
||||
let base = glam::Mat4::from_rotation_y((180.0 - stand.yaw).to_radians())
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let mut verts = Vec::new();
|
||||
self.armor_stand_vertices(&stand, base, 240 | 240 << 8, &mut verts);
|
||||
// ARMOR_STAND_ANGLE: rotationXYZ(0.43633232, 0, PI).
|
||||
let gui =
|
||||
glam::Mat4::from_translation(glam::Vec3::new((x0 + x1) / 2.0, (y0 + y1) / 2.0, 0.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(25.0, 25.0, -25.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 1.0, 0.0))
|
||||
* glam::Mat4::from_rotation_x(0.436_332_32)
|
||||
* glam::Mat4::from_rotation_z(std::f32::consts::PI);
|
||||
let quads =
|
||||
super::perspective::player_preview_quads(verts, gui, None, &self.gui.atlas.sprites);
|
||||
let start = batch.vertices.len();
|
||||
for (_, p, uv, color) in quads {
|
||||
batch.quad_3d(p, uv, color);
|
||||
}
|
||||
batch.depth_since(start, [x0, y0, x1, y1]);
|
||||
}
|
||||
}
|
||||
/// The stand's slot (`ArmorStand::SLOTS` order) for an item whose
|
||||
/// `equippable` slot is `equip` (`SmithingScreen.updateArmorStandPreview`):
|
||||
/// worn in a head, chest, legs or feet slot, else (no `equippable`, or any
|
||||
/// other slot: main hand, off hand, body, saddle) held in the left hand.
|
||||
fn preview_slot(equip: Option<&str>) -> usize {
|
||||
match equip {
|
||||
Some("feet") => 2,
|
||||
Some("legs") => 3,
|
||||
Some("chest") => 4,
|
||||
Some("head") => 5,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
/// The `Pose` rotations on the parts of an armor stand model or its armor
|
||||
@@ -954,6 +1022,52 @@ fn overlaps(a: Aabb, b: Aabb) -> bool {
|
||||
);
|
||||
a0.x < b1.x && b0.x < a1.x && a0.y < b1.y && b0.y < a1.y && a0.z < b1.z && b0.z < a1.z
|
||||
}
|
||||
/// What `Equippable.swapWithEquipmentSlot` leaves once it has passed its
|
||||
/// checks: the piece worn in the slot, the hand, and the old piece bound for
|
||||
/// the inventory.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct ArmorSwap {
|
||||
worn: ItemStack,
|
||||
hand: Option<ItemStack>,
|
||||
to_inventory: Option<ItemStack>,
|
||||
}
|
||||
/// `Equippable.swapWithEquipmentSlot` after its checks. A single item swaps
|
||||
/// with the worn piece: the hand gets the worn piece (`copyAndClear`), or
|
||||
/// keeps the item in creative when nothing was worn; creative wears a copy
|
||||
/// (`copy` instead of `copyAndClear`). From a larger stack one item is worn
|
||||
/// (`consumeAndReturn`, which takes none in creative) and the worn piece goes
|
||||
/// to the inventory.
|
||||
fn swap_with_equipment_slot(
|
||||
stack: &ItemStack,
|
||||
current: Option<ItemStack>,
|
||||
creative: bool,
|
||||
) -> ArmorSwap {
|
||||
let one = ItemStack {
|
||||
count: 1,
|
||||
..stack.clone()
|
||||
};
|
||||
if stack.count <= 1 {
|
||||
let hand = match current {
|
||||
Some(old) => Some(old),
|
||||
None if creative => Some(stack.clone()),
|
||||
None => None,
|
||||
};
|
||||
return ArmorSwap {
|
||||
worn: one,
|
||||
hand,
|
||||
to_inventory: None,
|
||||
};
|
||||
}
|
||||
let mut held = stack.clone();
|
||||
if !creative {
|
||||
held.count -= 1;
|
||||
}
|
||||
ArmorSwap {
|
||||
worn: one,
|
||||
hand: Some(held),
|
||||
to_inventory: current,
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `Equippable.swapWithEquipmentSlot`: right-clicking armor swaps it into
|
||||
/// its slot (the worn piece comes back to the hand).
|
||||
@@ -979,28 +1093,19 @@ impl Game<'_> {
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
let equipped = ItemStack {
|
||||
count: 1,
|
||||
..stack.clone()
|
||||
};
|
||||
self.armor[index] = Some(equipped.clone());
|
||||
let swap = swap_with_equipment_slot(stack, current, self.creative);
|
||||
self.armor[index] = Some(swap.worn.clone());
|
||||
// Player.setItemSlot: onEquipItem plays the equip sound.
|
||||
self.player_equipped(slot, Some(&equipped));
|
||||
if !self.creative {
|
||||
let mut held = stack.clone();
|
||||
held.count -= 1;
|
||||
if held.count == 0 {
|
||||
// The worn piece comes back to the hand.
|
||||
*self.held_mut() = current;
|
||||
} else {
|
||||
*self.held_mut() = Some(held);
|
||||
if let Some(old) = current
|
||||
&& let Some(rest) = self.give(old)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.spawn_item(rest, eye, [0.0; 3], 40);
|
||||
}
|
||||
}
|
||||
self.player_equipped(slot, Some(&swap.worn));
|
||||
*self.held_mut() = swap.hand;
|
||||
// Inventory.add, else Player.drop (in creative a full inventory
|
||||
// takes it anyway: hasInfiniteMaterials empties the stack).
|
||||
if let Some(old) = swap.to_inventory
|
||||
&& let Some(rest) = self.give(old)
|
||||
&& !self.creative
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.spawn_item(rest, eye, [0.0; 3], 40);
|
||||
}
|
||||
self.apply_equipment();
|
||||
self.swing_arm();
|
||||
@@ -1163,6 +1268,52 @@ fn broken_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
|
||||
(pos, drops)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod swap_tests {
|
||||
use super::*;
|
||||
fn stack(item: &str, count: u32) -> ItemStack {
|
||||
ItemStack::new(item, count)
|
||||
}
|
||||
#[test]
|
||||
fn creative_swap_gives_the_worn_piece_to_the_hand() {
|
||||
let swap = swap_with_equipment_slot(
|
||||
&stack("minecraft:diamond_chestplate", 1),
|
||||
Some(stack("minecraft:iron_chestplate", 1)),
|
||||
true,
|
||||
);
|
||||
assert_eq!(swap.worn, stack("minecraft:diamond_chestplate", 1));
|
||||
assert_eq!(swap.hand, Some(stack("minecraft:iron_chestplate", 1)));
|
||||
assert_eq!(swap.to_inventory, None);
|
||||
}
|
||||
#[test]
|
||||
fn creative_equip_with_nothing_worn_keeps_the_hand() {
|
||||
let swap = swap_with_equipment_slot(&stack("minecraft:diamond_helmet", 1), None, true);
|
||||
assert_eq!(swap.worn, stack("minecraft:diamond_helmet", 1));
|
||||
assert_eq!(swap.hand, Some(stack("minecraft:diamond_helmet", 1)));
|
||||
}
|
||||
#[test]
|
||||
fn survival_swap_gives_the_worn_piece_or_empties_the_hand() {
|
||||
let worn = Some(stack("minecraft:iron_boots", 1));
|
||||
let swap = swap_with_equipment_slot(&stack("minecraft:diamond_boots", 1), worn, false);
|
||||
assert_eq!(swap.hand, Some(stack("minecraft:iron_boots", 1)));
|
||||
let swap = swap_with_equipment_slot(&stack("minecraft:diamond_boots", 1), None, false);
|
||||
assert_eq!(swap.hand, None);
|
||||
}
|
||||
#[test]
|
||||
fn a_larger_stack_wears_one_and_sends_the_old_piece_to_the_inventory() {
|
||||
// Armour stacks past one only with a max_stack_size component.
|
||||
let worn = Some(stack("minecraft:leather_helmet", 1));
|
||||
let swap =
|
||||
swap_with_equipment_slot(&stack("minecraft:golden_helmet", 3), worn.clone(), false);
|
||||
assert_eq!(swap.worn, stack("minecraft:golden_helmet", 1));
|
||||
assert_eq!(swap.hand, Some(stack("minecraft:golden_helmet", 2)));
|
||||
assert_eq!(swap.to_inventory, worn);
|
||||
let swap =
|
||||
swap_with_equipment_slot(&stack("minecraft:golden_helmet", 3), worn.clone(), true);
|
||||
assert_eq!(swap.hand, Some(stack("minecraft:golden_helmet", 3)));
|
||||
assert_eq!(swap.to_inventory, worn);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod burn_drop_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
@@ -1194,6 +1345,25 @@ mod burn_drop_tests {
|
||||
#[cfg(test)]
|
||||
mod layer_color_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn the_smithing_preview_wears_armour_and_holds_the_rest_in_its_off_hand() {
|
||||
assert_eq!(preview_slot(Some("head")), 5);
|
||||
assert_eq!(preview_slot(Some("chest")), 4);
|
||||
assert_eq!(preview_slot(Some("legs")), 3);
|
||||
assert_eq!(preview_slot(Some("feet")), 2);
|
||||
// A netherite sword has no equippable slot; a saddle or a
|
||||
// main-hand item falls to the same default branch.
|
||||
for other in [
|
||||
None,
|
||||
Some("mainhand"),
|
||||
Some("offhand"),
|
||||
Some("body"),
|
||||
Some("saddle"),
|
||||
] {
|
||||
assert_eq!(preview_slot(other), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dyed_stack_overrides_the_undyed_leather_colour() {
|
||||
let mut dyed = ItemStack::new("minecraft:leather_helmet", 1);
|
||||
|
||||
@@ -562,27 +562,23 @@ impl Game<'_> {
|
||||
"gui/sprites/container/beacon/cancel",
|
||||
);
|
||||
let slots: Vec<usize> = (0..36).chain([INPUT]).collect();
|
||||
self.draw_hovered_slot_highlight(batch, false, ox, oy);
|
||||
for i in slots {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
if let Some(stack) = self.slot_mut(i).clone() {
|
||||
self.draw_item(batch, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
}
|
||||
self.draw_hovered_slot_highlight(batch, true, ox, oy);
|
||||
let mut stack_tip = None;
|
||||
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
ox + sx,
|
||||
oy + sy,
|
||||
16.0,
|
||||
16.0,
|
||||
rgba(255, 255, 255, 128),
|
||||
);
|
||||
stack_tip = self.slot_mut(slot).clone();
|
||||
}
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
if let Some(stack) = stack_tip.filter(|s| self.tooltip_with_carried(s)) {
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
} else if let Some(stack) = stack_tip {
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
|
||||
@@ -16,9 +16,11 @@ use horizon_client::{
|
||||
},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::collision_context::CollisionContext;
|
||||
use std::{collections::HashMap, f32::consts::PI, sync::Arc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -339,14 +341,37 @@ impl Game<'_> {
|
||||
if in_water {
|
||||
return Status::InWater;
|
||||
}
|
||||
// getGroundFriction over the blocks just below the hull (corners skipped).
|
||||
let y0 = (lo.y - 0.001).floor() as i32;
|
||||
let (x0, x1, z0, z1) = (
|
||||
lo.x.floor() as i32 - 1,
|
||||
hi.x.ceil() as i32 + 1,
|
||||
lo.z.floor() as i32 - 1,
|
||||
hi.z.ceil() as i32 + 1,
|
||||
let friction = self.boat_ground_friction(bb);
|
||||
if friction > 0.0 {
|
||||
b.land_friction = friction;
|
||||
Status::OnLand
|
||||
} else {
|
||||
Status::InAir
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.getGroundFriction`: average friction of the blocks whose
|
||||
/// collision shape meets a thin slab just under the hull (lily pads skipped).
|
||||
fn boat_ground_friction(&self, bb: Aabb) -> f32 {
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let ground = Aabb::new(
|
||||
Vec3::new(lo.x, lo.y - 0.001, lo.z),
|
||||
Vec3::new(hi.x, lo.y, hi.z),
|
||||
);
|
||||
let (g_lo, g_hi) = (ground.min_position(), ground.max_position());
|
||||
let (x0, x1, y0, y1, z0, z1) = (
|
||||
g_lo.x.floor() as i32 - 1,
|
||||
g_hi.x.ceil() as i32 + 1,
|
||||
g_lo.y.floor() as i32 - 1,
|
||||
g_hi.y.ceil() as i32 + 1,
|
||||
g_lo.z.floor() as i32 - 1,
|
||||
g_hi.z.ceil() as i32 + 1,
|
||||
);
|
||||
let slab = VoxelShape::from_box(ground).unwrap_or_else(|_| VoxelShape::empty());
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let (mut friction, mut count) = (0.0f32, 0);
|
||||
for x in x0..x1 {
|
||||
for z in z0..z1 {
|
||||
@@ -354,22 +379,39 @@ impl Game<'_> {
|
||||
if edges == 2 {
|
||||
continue;
|
||||
}
|
||||
let inside = f64::from(x) < hi.x
|
||||
&& f64::from(x + 1) > lo.x
|
||||
&& f64::from(z) < hi.z
|
||||
&& f64::from(z + 1) > lo.z;
|
||||
let f = self.res.facts().get(self.world.block(x, y0, z));
|
||||
if inside && (f.full_collision || f.legacy_solid) && !f.has_class("LilyPadBlock") {
|
||||
friction += f.friction;
|
||||
count += 1;
|
||||
for y in y0..y1 {
|
||||
if edges == 1 && (y == y0 || y == y1 - 1) {
|
||||
continue;
|
||||
}
|
||||
let state = self.world.block(x, y, z);
|
||||
let f = self.res.facts().get(state);
|
||||
if f.name.as_ref() == "minecraft:air" || f.has_class("LilyPadBlock") {
|
||||
continue;
|
||||
}
|
||||
let Ok(shape) = self.collision.collision_shape_with_context(
|
||||
&mut scene,
|
||||
state,
|
||||
BlockPos::new(x, y, z),
|
||||
&CollisionContext::empty(false),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let world_shape =
|
||||
shape.moved(Vec3::new(f64::from(x), f64::from(y), f64::from(z)));
|
||||
if world_shape
|
||||
.join_is_not_empty(&slab, ShapeOp::AND)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
friction += f.friction;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 0 && friction > 0.0 {
|
||||
b.land_friction = friction / count as f32;
|
||||
Status::OnLand
|
||||
if count > 0 {
|
||||
friction / count as f32
|
||||
} else {
|
||||
Status::InAir
|
||||
0.0
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.tick` for one boat (`controlled` when the player rides it).
|
||||
|
||||
@@ -378,6 +378,11 @@ impl Game<'_> {
|
||||
Some(KeyCode::PageDown) => self.turn_page(true),
|
||||
Some(KeyCode::ArrowUp) => self.book_move_line(-1),
|
||||
Some(KeyCode::ArrowDown) => self.book_move_line(1),
|
||||
// MultilineTextField.keyPressed: Home and End seek the caret's
|
||||
// line; with Ctrl they go to the page's start and end.
|
||||
Some(edge @ (KeyCode::Home | KeyCode::End)) if !self.has_control_down() => {
|
||||
self.book_line_edge(edge == KeyCode::End)
|
||||
}
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"),
|
||||
Some(key) if self.book_page_key(key) => {}
|
||||
_ => {
|
||||
@@ -542,6 +547,26 @@ impl Game<'_> {
|
||||
page_edit.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// Home and End: the start or end of the visual line holding the caret
|
||||
/// (`getCursorLineView`'s beginIndex and endIndex).
|
||||
fn book_line_edge(&mut self, end: bool) {
|
||||
let shift = self.has_shift_down();
|
||||
let (text, cursor) = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) => (pages[*page].clone(), page_edit.cursor),
|
||||
_ => return,
|
||||
};
|
||||
let rows = self.page_rows(&text);
|
||||
let (_, line) = self.caret_on(&text, &rows, cursor);
|
||||
let (start, stop) = rows[line];
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.set_cursor(if end { stop } else { start }, shift);
|
||||
}
|
||||
}
|
||||
/// Wrapped rows of a page as character ranges, the same breaks as `font.split`.
|
||||
fn page_rows(&self, page: &str) -> Vec<(usize, usize)> {
|
||||
let font = &self.gui.atlas.font;
|
||||
@@ -1075,7 +1100,7 @@ impl Game<'_> {
|
||||
self.turn_page(true);
|
||||
} else if page > 0 && inside(left + 43.0, 159.0, 23.0, 13.0) {
|
||||
self.turn_page(false);
|
||||
} else if let Some(key) = lectern {
|
||||
} else if let Some(key) = lectern.filter(|_| self.may_build()) {
|
||||
if inside(w / 2.0 - 100.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
self.close_book();
|
||||
@@ -1330,7 +1355,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
page_buttons(batch, *page > 0, *page + 1 < pages.len());
|
||||
if lectern.is_some() {
|
||||
// LecternScreen.createMenuControls: Done and Take Book only with
|
||||
// build permission, else BookViewScreen's wide Done.
|
||||
if lectern.is_some() && self.may_build() {
|
||||
button(batch, w / 2.0 - 100.0, 98.0, &tr("gui.done", "Done"), true);
|
||||
button(
|
||||
batch,
|
||||
|
||||
@@ -26,12 +26,15 @@ pub(super) fn takes_bundle_wheel(screen: Screen) -> bool {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
|
||||
/// container's own slots. A server only knows these in the inventory and chest-like menus.
|
||||
/// A slot a bundle can be selected in: the player's inventory and off hand in any
|
||||
/// menu (#4436: a crafting table's, an enchanting table's...), and a container's own
|
||||
/// slots. A server only knows those in chest-like menus.
|
||||
fn bundle_slot(&mut self, slot: usize) -> bool {
|
||||
(slot < 36 || slot == 504 || (300..500).contains(&slot))
|
||||
&& (self.remote.is_none() || self.remote_storage_open())
|
||||
&& (slot < 36 || slot == 504 || self.slots().contains(&slot))
|
||||
slot < 36
|
||||
|| slot == 504
|
||||
|| ((300..500).contains(&slot)
|
||||
&& (self.remote.is_none() || self.remote_storage_open())
|
||||
&& self.slots().contains(&slot))
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection
|
||||
|
||||
@@ -1129,6 +1129,12 @@ impl Game<'_> {
|
||||
let height = batch.height as i32;
|
||||
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
|
||||
let wrap = (batch.width as i32 / 2).max(200);
|
||||
// handleTagIcon: the chat-modified icons over the cursor or its message.
|
||||
let (tag_tooltip, tag_icons) =
|
||||
self.ui
|
||||
.chat
|
||||
.focused_tags(&self.gui.atlas.font, height, cx, cy);
|
||||
horizon_client::hud_layout::paint(batch, &self.gui.atlas, &tag_icons);
|
||||
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
|
||||
// DrawingFocusedGraphicsAccess.handleMessage: the pointing hand over
|
||||
// insertion text, which ChatScreen only asks for while Shift is held.
|
||||
@@ -1156,10 +1162,9 @@ impl Game<'_> {
|
||||
let lines = Self::entity_tooltip_lines(entity);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
} else if self.ui.chat.indicator_at(height, cx, cy) {
|
||||
// GuiMessageTag.systemSinglePlayer's text, split at 210.
|
||||
let tag = Text::translate("chat.tag.system_single_player", &[]);
|
||||
let lines = self.wrapped_rows(&tag, 210);
|
||||
} else if let Some(tag) = &tag_tooltip {
|
||||
// The message tag's text, split at 210.
|
||||
let lines = self.wrapped_rows(tag, 210);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,9 +224,6 @@ impl Game<'_> {
|
||||
};
|
||||
(d.name.clone(), *amount)
|
||||
}
|
||||
MotionEvent::Health(HealthEvent::StarvationDamage(amount)) => {
|
||||
("minecraft:starve".to_owned(), *amount)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let attacker = self
|
||||
|
||||
@@ -14,6 +14,7 @@ pub(super) mod pregen;
|
||||
mod profiles;
|
||||
mod raid;
|
||||
mod terrain_cmds;
|
||||
mod test_locate;
|
||||
mod test_runs;
|
||||
mod text_commands;
|
||||
mod world_cmds;
|
||||
@@ -971,6 +972,8 @@ impl Game<'_> {
|
||||
id if id.starts_with("posteffect.") => self.cmd_posteffect(id, ctx),
|
||||
"kick" => self.cmd_kick(ctx),
|
||||
"op" | "deop" | "ban" | "pardon" => self.cmd_profiles(id, ctx),
|
||||
"ban-ip" | "pardon-ip" => self.cmd_ip_bans(id, ctx),
|
||||
id if id.starts_with("banlist") => self.cmd_banlist(id),
|
||||
id if id.starts_with("whitelist.") => self.cmd_whitelist(id, ctx),
|
||||
"fetchprofile" => self.cmd_fetchprofile(ctx),
|
||||
"publish" => self.cmd_publish(ctx),
|
||||
@@ -2798,6 +2801,12 @@ impl Game<'_> {
|
||||
&[name(i)],
|
||||
)));
|
||||
}
|
||||
self.set_difficulty_forced(i);
|
||||
self.feedback(Text::translate("commands.difficulty.success", &[name(i)]));
|
||||
Ok(0)
|
||||
}
|
||||
/// `MinecraftServer.setDifficulty(difficulty, true)`.
|
||||
pub(super) fn set_difficulty_forced(&mut self, i: u8) {
|
||||
self.ui.difficulty = i;
|
||||
// The level data holds the world's difficulty: it survives the
|
||||
// reopen, so it follows the command.
|
||||
@@ -2816,8 +2825,6 @@ impl Game<'_> {
|
||||
locked: self.level.difficulty_locked,
|
||||
}));
|
||||
}
|
||||
self.feedback(Text::translate("commands.difficulty.success", &[name(i)]));
|
||||
Ok(0)
|
||||
}
|
||||
fn cmd_spawnpoint(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
|
||||
@@ -674,36 +674,21 @@ impl Game<'_> {
|
||||
// ---- /test ----------------------------------------------------------
|
||||
|
||||
/// `TestCommand`: running, resetting and clearing tests is
|
||||
/// `test_runs`; `verify`, `create`, `locate` and `pos` are not ported and
|
||||
/// answer as in a world without test instance blocks.
|
||||
/// `test_runs`; `locate`, `pos` and `create` are `test_locate`.
|
||||
pub(super) fn cmd_test(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
match id {
|
||||
"test.run" | "test.run.found" | "test.clear" | "test.stop" => {
|
||||
self.cmd_test_runs(id, ctx)
|
||||
}
|
||||
// verify, create: not ported.
|
||||
"test.verify" | "test.create" => Err(err("command.failed", &[])),
|
||||
"test.locate" => {
|
||||
// CommandSourceStack.sendSystemMessage: no feedback rule.
|
||||
if !self.command_source.is_some_and(|s| s.silent) {
|
||||
self.system_message(Text::translate("commands.test.locate.started", &[]));
|
||||
}
|
||||
Err(err("commands.test.error.no_test_instances", &[]))
|
||||
}
|
||||
_ => {
|
||||
// TestCommand.showPos: the block the player looks at (10 blocks).
|
||||
self.source_player()?;
|
||||
let p = self.test_pick_block();
|
||||
Err(err(
|
||||
"commands.test.error.no_test_containing_pos",
|
||||
&[&p[0], &p[1], &p[2]],
|
||||
))
|
||||
}
|
||||
"test.verify" => self.test_verify(ctx),
|
||||
"test.create" => self.test_create(ctx),
|
||||
"test.locate" => self.test_locate(ctx),
|
||||
_ => self.test_show_pos(ctx),
|
||||
}
|
||||
}
|
||||
/// `Entity.pick(10, 1, false)`: the first non-air block on the view ray,
|
||||
/// else the block at the ray's end.
|
||||
fn test_pick_block(&self) -> [i32; 3] {
|
||||
pub(super) fn test_pick_block(&self) -> [i32; 3] {
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, traverse_blocks},
|
||||
position::BlockPos,
|
||||
|
||||
@@ -24,6 +24,8 @@ pub(in crate::game) enum Slot {
|
||||
Stand(u32, usize),
|
||||
Frame(u32),
|
||||
Block([i32; 3], usize),
|
||||
/// A slot of another player (`for_player`): reached with their state in place.
|
||||
Guest(u32, Box<Slot>),
|
||||
}
|
||||
/// Whose slots a command addresses.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -73,6 +75,16 @@ impl Game<'_> {
|
||||
}
|
||||
/// The slots a name (or wildcard group) addresses on an owner.
|
||||
pub(in crate::game) fn slots_named(&mut self, owner: &Owner, name: &str) -> Vec<Slot> {
|
||||
if let Owner::Entity(Target::Guest(conn)) = *owner {
|
||||
let slots = self.for_player(Target::Guest(conn), |g| {
|
||||
g.slots_named(&Owner::Entity(Target::Player), name)
|
||||
});
|
||||
return slots
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|s| Slot::Guest(conn, Box::new(s)))
|
||||
.collect();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut add = |s: Slot| out.push(s);
|
||||
match owner {
|
||||
@@ -157,8 +169,11 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
pub(in crate::game) fn slot_get(&self, s: &Slot) -> Option<ItemStack> {
|
||||
pub(in crate::game) fn slot_get(&mut self, s: &Slot) -> Option<ItemStack> {
|
||||
match s {
|
||||
Slot::Guest(conn, slot) => self
|
||||
.for_player(Target::Guest(*conn), |g| g.slot_get(slot))
|
||||
.flatten(),
|
||||
Slot::Inv(i) => self.inventory.get(*i).cloned().flatten(),
|
||||
Slot::Armor(i) => self.armor.get(*i).cloned().flatten(),
|
||||
Slot::Offhand => self.offhand.clone(),
|
||||
@@ -201,6 +216,9 @@ impl Game<'_> {
|
||||
/// Put a stack (or nothing) in a slot; false when the slot cannot take it.
|
||||
pub(in crate::game) fn slot_set(&mut self, s: &Slot, stack: Option<ItemStack>) -> bool {
|
||||
match s {
|
||||
Slot::Guest(conn, slot) => self
|
||||
.for_player(Target::Guest(*conn), |g| g.slot_set(slot, stack))
|
||||
.unwrap_or(false),
|
||||
Slot::Inv(i) => match self.inventory.get_mut(*i) {
|
||||
Some(slot) => {
|
||||
*slot = stack;
|
||||
@@ -603,7 +621,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `ItemStack.getMaxStackSize`: the max_stack_size component, else the
|
||||
/// item's.
|
||||
pub(super) fn stack_max_size(&self, s: &ItemStack) -> u32 {
|
||||
pub(in crate::game) fn stack_max_size(&self, s: &ItemStack) -> u32 {
|
||||
if let Some(Tag::Int(n)) = s.data().and_then(|d| d.other("minecraft:max_stack_size")) {
|
||||
return (*n).clamp(1, 99) as u32;
|
||||
}
|
||||
|
||||
@@ -156,9 +156,12 @@ impl Game<'_> {
|
||||
max_depth,
|
||||
position: pos,
|
||||
};
|
||||
let placed = self.with_level(|b, level, ticks| {
|
||||
let (placed, entities) = self.with_level(|b, level, ticks| {
|
||||
b.place_jigsaw(level, ticks, pos, false, || terrain.jigsaw_pieces(&query))
|
||||
});
|
||||
for entity in entities {
|
||||
self.spawn_entity_from_nbt(Tag::Compound(entity));
|
||||
}
|
||||
self.process_level_events()
|
||||
.map_err(|_| err("command.failed", &[]))?;
|
||||
if !placed {
|
||||
@@ -182,9 +185,37 @@ impl Game<'_> {
|
||||
{
|
||||
return Err(err("commands.place.structure.invalid", &[&structure]));
|
||||
}
|
||||
self.place_pos(ctx, "pos")?;
|
||||
// Structure.generate is not wired to the live level.
|
||||
Err(err("command.failed", &[]))
|
||||
let pos = self.place_pos(ctx, "pos")?;
|
||||
// PlaceCommand.placeStructure: Structure.generate for the
|
||||
// position's chunk with its `biome -> true` predicate, then
|
||||
// placeInChunk for every chunk of the start's box.
|
||||
let terrain = self
|
||||
.end
|
||||
.locator()
|
||||
.ok_or_else(|| err("command.failed", &[]))?;
|
||||
let start = terrain
|
||||
.structure_start(&structure, pos[0] >> 4, pos[2] >> 4)
|
||||
.ok_or_else(|| err("commands.place.structure.failed", &[]))?;
|
||||
let (min, max) = (start.bounding_box.min, start.bounding_box.max);
|
||||
self.place_check_loaded([min[0] >> 4, min[2] >> 4], [max[0] >> 4, max[2] >> 4])?;
|
||||
let (placed, entities) =
|
||||
self.with_level(|b, level, ticks| b.place_structure(level, ticks, &start));
|
||||
for entity in entities {
|
||||
self.spawn_entity_from_nbt(Tag::Compound(entity));
|
||||
}
|
||||
if !placed {
|
||||
return Err(err("commands.place.structure.failed", &[]));
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.place.structure.success",
|
||||
&[
|
||||
Text::literal(structure),
|
||||
num(pos[0]),
|
||||
num(pos[1]),
|
||||
num(pos[2]),
|
||||
],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `/op`, `/deop`, `/ban`, `/pardon` and `/whitelist` (OpCommand,
|
||||
//! DeOpCommands, BanPlayerCommands, PardonCommand, WhitelistCommand):
|
||||
//! `/op`, `/deop`, `/ban`, `/pardon`, `/ban-ip`, `/pardon-ip`, `/banlist` and
|
||||
//! `/whitelist` (OpCommand, DeOpCommands, BanPlayerCommands, PardonCommand,
|
||||
//! BanIpCommands, PardonIpCommand, BanListCommands, WhitelistCommand):
|
||||
//! commands of a dedicated server, run on its lists by an operator in game
|
||||
//! (the console has its own handlers in game/dedicated.rs).
|
||||
use super::{Arg, Context, Game, R, Target, err};
|
||||
@@ -183,6 +184,147 @@ impl Game<'_> {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// `BanIpCommands.banIpOrName` and `PardonIpCommand.unban`: an address,
|
||||
/// or for `/ban-ip` an online player's.
|
||||
pub(super) fn cmd_ip_bans(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let lists = self.dedicated_lists()?;
|
||||
let lit = |s: &str| Text::literal(s.to_owned());
|
||||
let target = match ctx.get("target") {
|
||||
Some(Arg::Str(t)) => t.clone(),
|
||||
_ => return Err(err("command.unknown.argument", &[])),
|
||||
};
|
||||
let is_ip = target.parse::<std::net::IpAddr>().is_ok();
|
||||
if id == "pardon-ip" {
|
||||
if !is_ip {
|
||||
return Err(err("commands.pardonip.invalid", &[]));
|
||||
}
|
||||
let removed = {
|
||||
let mut l = lists.lock().unwrap();
|
||||
l.ip_bans.get(&target).is_some() && l.ip_bans.remove(&target)
|
||||
};
|
||||
if !removed {
|
||||
return Err(err("commands.pardonip.failed", &[]));
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.pardonip.success",
|
||||
&[lit(&target)],
|
||||
));
|
||||
return Ok(1);
|
||||
}
|
||||
// PlayerList.getPlayerByName, then ServerPlayer.getIpAddress.
|
||||
let address = |name: &str| {
|
||||
let hub = &self.lan.as_ref()?.hub;
|
||||
hub.guests
|
||||
.iter()
|
||||
.find(|g| g.name.eq_ignore_ascii_case(name))
|
||||
.map(|g| g.address.ip().to_string())
|
||||
};
|
||||
let ip = if is_ip {
|
||||
Some(target)
|
||||
} else {
|
||||
address(&target)
|
||||
};
|
||||
let Some(ip) = ip else {
|
||||
return Err(err("commands.banip.invalid", &[]));
|
||||
};
|
||||
if lists.lock().unwrap().ip_bans.get(&ip).is_some() {
|
||||
return Err(err("commands.banip.failed", &[]));
|
||||
}
|
||||
let players: Vec<(u128, String)> = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.map(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.address.ip().to_string() == ip)
|
||||
.map(|g| (g.uuid, g.name.clone()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let reason = match ctx.get("reason") {
|
||||
Some(Arg::Str(raw)) => {
|
||||
let raw = raw.clone();
|
||||
Some(self.expand_selectors(&raw).plain().to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let source = self.source_text_name();
|
||||
lists.lock().unwrap().ip_bans.add(BanEntry::new(
|
||||
ip.clone(),
|
||||
Some(source),
|
||||
None,
|
||||
reason.clone(),
|
||||
));
|
||||
self.feedback(Text::translate(
|
||||
"commands.banip.success",
|
||||
&[lit(&ip), Self::reason_message(reason.as_deref())],
|
||||
));
|
||||
if !players.is_empty() {
|
||||
let names: Vec<&str> = players.iter().map(|(_, n)| n.as_str()).collect();
|
||||
self.feedback(Text::translate(
|
||||
"commands.banip.info",
|
||||
&[lit(&players.len().to_string()), lit(&names.join(", "))],
|
||||
));
|
||||
}
|
||||
for (uuid, _) in &players {
|
||||
self.kick_uuid(*uuid, translatable("multiplayer.disconnect.ip_banned", &[]));
|
||||
}
|
||||
Ok(players.len() as i32)
|
||||
}
|
||||
|
||||
/// `BanListCommands.showList`: the players' bans then the addresses'
|
||||
/// (`banlist`), or one of them; told to the source only.
|
||||
pub(super) fn cmd_banlist(&mut self, id: &str) -> R<i32> {
|
||||
let lists = self.dedicated_lists()?;
|
||||
let lit = |s: &str| Text::literal(s.to_owned());
|
||||
let entries: Vec<(String, String, Option<String>)> = {
|
||||
let l = lists.lock().unwrap();
|
||||
let players = l
|
||||
.bans
|
||||
.entries()
|
||||
.into_iter()
|
||||
.map(|b| (b.user.name.clone(), b.source.clone(), b.reason.clone()));
|
||||
let ips = l
|
||||
.ip_bans
|
||||
.entries()
|
||||
.into_iter()
|
||||
.map(|b| (b.user.clone(), b.source.clone(), b.reason.clone()));
|
||||
match id {
|
||||
"banlist.ips" => ips.collect(),
|
||||
"banlist.players" => players.collect(),
|
||||
_ => players.chain(ips).collect(),
|
||||
}
|
||||
};
|
||||
if entries.is_empty() {
|
||||
self.feedback_unbroadcast(Text::translate("commands.banlist.none", &[]));
|
||||
return Ok(0);
|
||||
}
|
||||
self.feedback_unbroadcast(Text::translate(
|
||||
"commands.banlist.list",
|
||||
&[lit(&entries.len().to_string())],
|
||||
));
|
||||
for (name, source, reason) in &entries {
|
||||
self.feedback_unbroadcast(Text::translate(
|
||||
"commands.banlist.entry",
|
||||
&[
|
||||
lit(name),
|
||||
lit(source),
|
||||
Self::reason_message(reason.as_deref()),
|
||||
],
|
||||
));
|
||||
}
|
||||
Ok(entries.len() as i32)
|
||||
}
|
||||
|
||||
/// `BanListEntry.getReasonMessage`.
|
||||
fn reason_message(reason: Option<&str>) -> Text {
|
||||
reason.map_or_else(
|
||||
|| Text::translate("multiplayer.disconnect.banned.reason.default", &[]),
|
||||
|r| Text::literal(r.to_owned()),
|
||||
)
|
||||
}
|
||||
|
||||
/// `WhitelistCommand`: `on`, `off`, `list`, `reload`, `add` and `remove`.
|
||||
pub(super) fn cmd_whitelist(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let lists = self.dedicated_lists()?;
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! `TestCommand`'s `locate`, `pos` and `create`.
|
||||
use super::{Arg, Context, Game, R, err};
|
||||
use crate::game::test_instances::TestDef;
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
test_instance_block::{Status, TestInstanceBlock},
|
||||
text::{self, ClickEvent, Style, Text},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_world::worldgen::structure::Rotation;
|
||||
|
||||
type Pos = (i32, i32, i32);
|
||||
|
||||
/// `Rotation.rotate(Direction.NORTH)` as (dx, dz) and the `toYRot` of the
|
||||
/// opposite direction.
|
||||
fn north_turned(rotation: Rotation) -> ((i32, i32), i32) {
|
||||
match rotation {
|
||||
Rotation::None => ((0, -1), 0),
|
||||
Rotation::Clockwise90 => ((1, 0), 90),
|
||||
Rotation::Clockwise180 => ((0, 1), 180),
|
||||
Rotation::Counterclockwise90 => ((-1, 0), 270),
|
||||
}
|
||||
}
|
||||
|
||||
/// `String.format("/execute in %s run tp @s %d %d %d %d 0", ..)` of `locate`.
|
||||
fn locate_command(dimension: &str, at: Pos, y_rot: i32) -> String {
|
||||
format!(
|
||||
"/execute in {dimension} run tp @s {} {} {} {y_rot} 0",
|
||||
at.0, at.1, at.2
|
||||
)
|
||||
}
|
||||
|
||||
/// `Mth.floor(Mth.sqrt(dx * dx + dz * dz))` over ints.
|
||||
fn horizontal_distance(dx: i32, dz: i32) -> i32 {
|
||||
((dx.wrapping_mul(dx).wrapping_add(dz.wrapping_mul(dz))) as f32)
|
||||
.sqrt()
|
||||
.floor() as i32
|
||||
}
|
||||
|
||||
/// The text of a clickable bracketed position.
|
||||
fn link(inner: Text, color: u32, command: String) -> Text {
|
||||
Text::literal("[")
|
||||
.append(inner)
|
||||
.append("]")
|
||||
.with_style(&Style {
|
||||
click: Some(ClickEvent::SuggestCommand(command)),
|
||||
hover: Some(Box::new(Text::translate("chat.coordinates.tooltip", &[]))),
|
||||
..Style::color(color)
|
||||
})
|
||||
}
|
||||
|
||||
fn path_of(dimension: &str) -> &str {
|
||||
dimension.split_once(':').map_or(dimension, |(_, p)| p)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `TestCommand.locate`.
|
||||
pub(super) fn test_locate(&mut self, ctx: &Context) -> R<i32> {
|
||||
let selected: Vec<String> = match ctx.get("tests") {
|
||||
Some(Arg::Str(pattern)) => horizon_client::command::args::select_resources(
|
||||
self.ui.registries.ids("minecraft:test_instance"),
|
||||
pattern,
|
||||
)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// CommandSourceStack.sendSystemMessage: no feedback rule.
|
||||
if !self.command_source.is_some_and(|s| s.silent) {
|
||||
self.system_message(Text::translate("commands.test.locate.started", &[]));
|
||||
}
|
||||
let center = self.test_source_block();
|
||||
let source_dim = self.dim;
|
||||
// findSelectedTestPositionsAcrossDimensions: every level's test
|
||||
// blocks within 250 blocks whose test was selected.
|
||||
let mut found: Vec<(Pos, String, Rotation)> = Vec::new();
|
||||
if !selected.is_empty() {
|
||||
for dim in self.loaded_dims() {
|
||||
let back = self.enter_command_level(dim);
|
||||
for pos in self.find_test_instance_blocks(center, 250) {
|
||||
let key = self.test_instance_at(pos).and_then(|b| b.data.test.clone());
|
||||
let key = key.map(|k| {
|
||||
if k.contains(':') {
|
||||
k
|
||||
} else {
|
||||
format!("minecraft:{k}")
|
||||
}
|
||||
});
|
||||
if key.is_some_and(|k| selected.contains(&k)) {
|
||||
found.push((pos, dim.id().to_owned(), self.test_rotation(pos)));
|
||||
}
|
||||
}
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut dimensions: Vec<String> = Vec::new();
|
||||
for (pos, dimension, rotation) in &found {
|
||||
if !dimensions.contains(dimension) {
|
||||
dimensions.push(dimension.clone());
|
||||
}
|
||||
let ((dx, dz), y_rot) = north_turned(*rotation);
|
||||
let tp = (pos.0 + dx * 3, pos.1, pos.2 + dz * 3);
|
||||
let command = locate_command(dimension, tp, y_rot);
|
||||
let coordinates = link(
|
||||
Text::tr("chat.coordinates", &[&pos.0, &pos.1, &pos.2])
|
||||
.append(format!(" ->[{}]", path_of(dimension))),
|
||||
text::GREEN,
|
||||
command,
|
||||
);
|
||||
let distance = if *dimension == source_dim.id() {
|
||||
horizontal_distance(center.0 - pos.0, center.2 - pos.2).to_string()
|
||||
} else {
|
||||
"N/A".to_owned()
|
||||
};
|
||||
self.feedback_unbroadcast(Text::translate(
|
||||
"commands.test.locate.found",
|
||||
&[coordinates, Text::literal(distance)],
|
||||
));
|
||||
}
|
||||
let count = found.len() as i32;
|
||||
if count == 0 {
|
||||
return Err(err("commands.test.error.no_test_instances", &[]));
|
||||
}
|
||||
self.output_player_coordinates(&dimensions);
|
||||
self.feedback(Text::translate(
|
||||
"commands.test.locate.done",
|
||||
&[Text::literal(count.to_string())],
|
||||
));
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// `outputPlayerCoordinates`.
|
||||
fn output_player_coordinates(&mut self, test_dimensions: &[String]) {
|
||||
let Some(player) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let dimension = self.dim.id().to_owned();
|
||||
// isPlayerInCurrentTestDimension
|
||||
if test_dimensions.len() <= 1
|
||||
&& test_dimensions.contains(&dimension)
|
||||
&& test_dimensions.iter().any(|d| d == "minecraft:overworld")
|
||||
{
|
||||
return;
|
||||
}
|
||||
let at = [
|
||||
player.position.x.floor() as i32,
|
||||
player.position.y.floor() as i32,
|
||||
player.position.z.floor() as i32,
|
||||
];
|
||||
let command = format!(
|
||||
"/execute in {dimension} run tp @s {} {} {} {} {}",
|
||||
at[0],
|
||||
at[1],
|
||||
at[2],
|
||||
self.yaw.floor() as i32,
|
||||
self.pitch.floor() as i32
|
||||
);
|
||||
let coordinates = link(
|
||||
Text::translate(
|
||||
"test.player.coordinates",
|
||||
&[
|
||||
Text::literal(at[0].to_string()),
|
||||
Text::literal(at[1].to_string()),
|
||||
Text::literal(at[2].to_string()),
|
||||
Text::literal(path_of(&dimension).to_owned()),
|
||||
],
|
||||
),
|
||||
text::GOLD,
|
||||
command,
|
||||
);
|
||||
self.feedback_unbroadcast(coordinates);
|
||||
}
|
||||
|
||||
/// `TestCommand.showPos`, but for the highlight the client draws
|
||||
/// (`ClientboundGameTestHighlightPosPacket`).
|
||||
pub(super) fn test_show_pos(&mut self, ctx: &Context) -> R<i32> {
|
||||
self.source_player()?;
|
||||
let var = match ctx.get("var") {
|
||||
Some(Arg::Str(v)) => v.clone(),
|
||||
_ => "pos".to_owned(),
|
||||
};
|
||||
let target = self.test_pick_block();
|
||||
let target = (target[0], target[1], target[2]);
|
||||
let containing = |game: &Self, radius: i32| {
|
||||
game.find_test_instance_blocks(target, radius)
|
||||
.into_iter()
|
||||
.find(|&p| {
|
||||
game.test_structure_box(p).is_some_and(|b| {
|
||||
(0..3).all(|i| {
|
||||
let t = [target.0, target.1, target.2][i];
|
||||
t >= b.min[i] && t <= b.max[i]
|
||||
})
|
||||
})
|
||||
})
|
||||
};
|
||||
let found = containing(self, 15).or_else(|| containing(self, 250));
|
||||
let Some(block) = found else {
|
||||
return Err(err(
|
||||
"commands.test.error.no_test_containing_pos",
|
||||
&[&target.0, &target.1, &target.2],
|
||||
));
|
||||
};
|
||||
let Some(origin) = self.test_structure_box(block).map(|b| b.min) else {
|
||||
return Err(err("commands.test.error.test_instance_not_found", &[]));
|
||||
};
|
||||
let relative = [
|
||||
target.0 - origin[0],
|
||||
target.1 - origin[1],
|
||||
target.2 - origin[2],
|
||||
];
|
||||
let description = format!("{}, {}, {}", relative[0], relative[1], relative[2]);
|
||||
let coords = Text::translate(
|
||||
"commands.test.coordinates",
|
||||
&relative.map(|v| Text::literal(v.to_string())),
|
||||
)
|
||||
.with_style(&Style {
|
||||
bold: Some(true),
|
||||
click: Some(ClickEvent::CopyToClipboard(format!(
|
||||
"final BlockPos {var} = new BlockPos({description});"
|
||||
))),
|
||||
hover: Some(Box::new(Text::translate(
|
||||
"commands.test.coordinates.copy",
|
||||
&[],
|
||||
))),
|
||||
..Style::color(text::GREEN)
|
||||
});
|
||||
let name = self.test_name(block).plain();
|
||||
self.feedback_unbroadcast(Text::translate(
|
||||
"commands.test.relative_position",
|
||||
&[Text::literal(name), coords],
|
||||
));
|
||||
// `player.connection.send(new ClientboundGameTestHighlightPosPacket(..))`.
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_packets
|
||||
.push(C::GameTestHighlightPos(play::GameTestHighlightPos {
|
||||
absolute: [target.0, target.1, target.2],
|
||||
relative,
|
||||
}));
|
||||
} else {
|
||||
self.highlight_test_pos([target.0, target.1, target.2], relative);
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `TestCommand.createNewStructure`.
|
||||
pub(super) fn test_create(&mut self, ctx: &Context) -> R<i32> {
|
||||
let int = |name: &str, default: i32| match ctx.get(name) {
|
||||
Some(Arg::Int(v)) => *v,
|
||||
_ => default,
|
||||
};
|
||||
let Some(Arg::Resource(id)) = ctx.get("id") else {
|
||||
return Ok(0);
|
||||
};
|
||||
let key = if id.contains(':') {
|
||||
id.clone()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
// `create <id> <width>` is a cube.
|
||||
let width = int("width", 5);
|
||||
let size = if ctx.get("height").is_some() {
|
||||
[width, int("height", 5), int("depth", 5)]
|
||||
} else {
|
||||
[width; 3]
|
||||
};
|
||||
if size.iter().any(|&v| v > 48) {
|
||||
return Err(err("commands.test.error.too_large", &[&48]));
|
||||
}
|
||||
let at = self.test_position_around();
|
||||
self.create_empty_test(&key, at, size);
|
||||
let padding = self.test_holder_padding(at);
|
||||
let low =
|
||||
TestInstanceBlock::structure_pos_of([at.0 + padding, at.1 + padding, at.2 + padding]);
|
||||
let bedrock = self.pack.assets.registry.default_state("minecraft:bedrock");
|
||||
if bedrock.is_some() {
|
||||
for x in low[0]..low[0] + size[0] {
|
||||
for z in low[2]..low[2] + size[2] {
|
||||
self.set_test_block_state(
|
||||
(x, low[1], z),
|
||||
"minecraft:bedrock",
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = self.test_name(at);
|
||||
self.feedback(Text::translate("commands.test.create.success", &[name]));
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `StructureUtils.createNewEmptyTest`.
|
||||
fn create_empty_test(&mut self, key: &str, at: Pos, size: [i32; 3]) {
|
||||
let padding = self.test_def(key).map_or(0, |d: TestDef| d.padding);
|
||||
let corner =
|
||||
TestInstanceBlock::structure_pos_of([at.0 + padding, at.1 + padding, at.2 + padding]);
|
||||
// getStructureBoundingBox(getStructurePos(testPos), size, NONE)
|
||||
let area = horizon_world::worldgen::structure::BoundingBox::new(
|
||||
corner,
|
||||
[0, 1, 2].map(|i| corner[i] + size[i] - 1),
|
||||
);
|
||||
self.clear_test_space(&area);
|
||||
self.set_test_block_state(
|
||||
at,
|
||||
"minecraft:test_instance_block",
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
if self.test_instance_at(at).is_none() {
|
||||
self.world.block_entities.insert(
|
||||
at,
|
||||
BlockEntity::TestInstanceBlock(Box::<TestInstanceBlock>::default()),
|
||||
);
|
||||
}
|
||||
if let Some(block) = self.test_instance_mut(at) {
|
||||
block.data.test = Some(key.to_owned());
|
||||
block.data.size = size;
|
||||
block.data.rotation = Rotation::None;
|
||||
block.data.ignore_entities = false;
|
||||
block.data.status = Status::Cleared;
|
||||
block.data.error_message = None;
|
||||
}
|
||||
self.mark_block_entity_updated(at);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn locate_teleports_three_blocks_in_front_of_the_rotated_north_face() {
|
||||
// Rotation.rotate(Direction.NORTH) and the toYRot of its opposite.
|
||||
assert_eq!(north_turned(Rotation::None), ((0, -1), 0));
|
||||
assert_eq!(north_turned(Rotation::Clockwise90), ((1, 0), 90));
|
||||
assert_eq!(north_turned(Rotation::Clockwise180), ((0, 1), 180));
|
||||
assert_eq!(north_turned(Rotation::Counterclockwise90), ((-1, 0), 270));
|
||||
assert_eq!(
|
||||
locate_command("minecraft:the_nether", (1, 64, -3), 90),
|
||||
"/execute in minecraft:the_nether run tp @s 1 64 -3 90 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_distance_is_the_floored_horizontal_one() {
|
||||
assert_eq!(horizontal_distance(3, 4), 5);
|
||||
assert_eq!(horizontal_distance(-1, 1), 1);
|
||||
assert_eq!(path_of("minecraft:overworld"), "overworld");
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,15 @@
|
||||
//! `runthat`, `runfailed`, `resetclosest`/`these`/`that`,
|
||||
//! `clearthat`/`these`/`all` and `stop`, with `TestFinder`'s searches.
|
||||
//!
|
||||
//! Not done: `verify`, `locate`, `pos`, `create`, retries (`numberOfTimes`,
|
||||
//! `untilFailed`), tests of another dimension than the level in place.
|
||||
//! Retries are `RetryOptions(numberOfTimes, untilFailed)`.
|
||||
use super::{Arg, Context, Game, R, err};
|
||||
use crate::game::{
|
||||
game_tests::{Grid, Info},
|
||||
Dim,
|
||||
game_tests::{Grid, Info, Retry, RunnerOptions, verify_batches},
|
||||
test_instances::TestDef,
|
||||
};
|
||||
use horizon_client::text::{self, Text};
|
||||
use horizon_world::worldgen::structure::BoundingBox;
|
||||
use horizon_world::worldgen::structure::Rotation;
|
||||
|
||||
type Pos = (i32, i32, i32);
|
||||
@@ -49,7 +50,7 @@ impl Game<'_> {
|
||||
|
||||
/// `StructureUtils.findTestBlocks`: the test instance blocks within
|
||||
/// `radius` of `center` (the POI search is a sphere).
|
||||
fn find_test_instance_blocks(&self, center: Pos, radius: i32) -> Vec<Pos> {
|
||||
pub(super) fn find_test_instance_blocks(&self, center: Pos, radius: i32) -> Vec<Pos> {
|
||||
let limit = i64::from(radius) * i64::from(radius);
|
||||
let mut found: Vec<Pos> = self
|
||||
.world
|
||||
@@ -72,7 +73,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The block of the source's position (`BlockPos.containing`).
|
||||
fn test_source_block(&self) -> Pos {
|
||||
pub(super) fn test_source_block(&self) -> Pos {
|
||||
let p = self.source_position();
|
||||
(
|
||||
p[0].floor() as i32,
|
||||
@@ -167,7 +168,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `createGameTestInfo`: the test of a test instance block.
|
||||
fn test_info_at(&mut self, pos: Pos) -> Option<Info> {
|
||||
fn test_info_at(&mut self, pos: Pos, retry: Retry) -> Option<Info> {
|
||||
let key = self.test_instance_at(pos).and_then(|b| b.data.test.clone());
|
||||
if self.test_instance_at(pos).is_none() {
|
||||
self.test_failure(Text::translate(
|
||||
@@ -195,45 +196,74 @@ impl Game<'_> {
|
||||
}
|
||||
let rotation = self.test_rotation(pos);
|
||||
let id = self.game_tests.next_id();
|
||||
Some(Info::new(id, key, def, Some(pos), self.dim, rotation))
|
||||
Some(Info::new(id, key, def, Some(pos), self.dim, rotation).with_retry(retry))
|
||||
}
|
||||
|
||||
/// `toGameTestInfo`: a test without a block, in the level in place.
|
||||
fn test_info_for(&mut self, key: String, def: TestDef, steps: i32) -> R<Option<Info>> {
|
||||
fn test_info_for(
|
||||
&mut self,
|
||||
key: String,
|
||||
def: TestDef,
|
||||
steps: i32,
|
||||
retry: Retry,
|
||||
) -> R<Option<Info>> {
|
||||
if !self.verify_test_structure(&def.structure) {
|
||||
return Ok(None);
|
||||
}
|
||||
if def.dimension != self.dim.id() {
|
||||
// `server.getLevel(dimension)`: the server's levels; a client-only
|
||||
// game (deprecated in-frame mode) has the one it is in.
|
||||
let dim = match def.dimension.as_str() {
|
||||
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" => {
|
||||
Some(Dim::from_id(&def.dimension))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
.filter(|d| *d == self.dim || !self.options.execution_role.is_client());
|
||||
let Some(dim) = dim else {
|
||||
self.test_failure(Text::literal(format!(
|
||||
"Could not resolve level for dimension: {}",
|
||||
def.dimension
|
||||
)));
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let rotation = rotation_for_steps(steps).ok_or_else(|| err("command.failed", &[]))?;
|
||||
let id = self.game_tests.next_id();
|
||||
Ok(Some(Info::new(id, key, def, None, self.dim, rotation)))
|
||||
Ok(Some(
|
||||
Info::new(id, key, def, None, dim, rotation).with_retry(retry),
|
||||
))
|
||||
}
|
||||
|
||||
/// The column `createTestPositionAround` starts from: the source's.
|
||||
pub(super) fn test_around(&self) -> (i32, i32) {
|
||||
let p = self.source_position();
|
||||
(p[0].floor() as i32, p[2].floor() as i32)
|
||||
}
|
||||
|
||||
/// `createTestPositionAround`: three blocks south of the source, on the
|
||||
/// surface of the level.
|
||||
fn test_position_around(&self) -> Pos {
|
||||
let p = self.source_position();
|
||||
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
|
||||
/// surface of the level in place (`playerAndTestInfo` loads the chunk of
|
||||
/// the source's column in that level first).
|
||||
pub(in crate::game) fn test_position_in_level(&mut self, (x, z): (i32, i32)) -> Pos {
|
||||
self.load_test_chunks(&BoundingBox::new([x, 0, z], [x, 0, z]));
|
||||
let facts = self.res.facts();
|
||||
let surface = self.world.world_surface(x, z + 3, |s| facts.is_air(s));
|
||||
let surface = self.world.world_surface(x, z, |s| facts.is_air(s));
|
||||
(x, surface, z + 3)
|
||||
}
|
||||
|
||||
/// `createTestPositionAround` in the level in place.
|
||||
pub(super) fn test_position_around(&mut self) -> Pos {
|
||||
let around = self.test_around();
|
||||
self.test_position_in_level(around)
|
||||
}
|
||||
|
||||
/// `TestCommand.run`.
|
||||
fn run_tests(&mut self, finder: &Finder, steps: i32, per_row: i32) -> R<i32> {
|
||||
fn run_tests(&mut self, finder: &Finder, retry: Retry, steps: i32, per_row: i32) -> R<i32> {
|
||||
self.clear_game_tests();
|
||||
let mut infos = Vec::new();
|
||||
for pos in self.finder_positions(finder) {
|
||||
infos.extend(self.test_info_at(pos));
|
||||
infos.extend(self.test_info_at(pos, retry));
|
||||
}
|
||||
for (key, def) in self.finder_tests(finder) {
|
||||
infos.extend(self.test_info_for(key, def, steps)?);
|
||||
infos.extend(self.test_info_for(key, def, steps, retry)?);
|
||||
}
|
||||
if infos.is_empty() {
|
||||
self.feedback_unbroadcast(Text::translate("commands.test.no_tests", &[]));
|
||||
@@ -244,17 +274,45 @@ impl Game<'_> {
|
||||
"commands.test.run.running",
|
||||
&[Text::literal(infos.len().to_string())],
|
||||
));
|
||||
let grid = Grid::new(self.test_position_around(), per_row);
|
||||
let grid = Grid::new(self.test_around(), per_row);
|
||||
self.track_and_start_runner(infos, false, Some(grid));
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `TestCommand.verify`.
|
||||
pub(super) fn test_verify(&mut self, ctx: &Context) -> R<i32> {
|
||||
self.clear_game_tests();
|
||||
let tests = match ctx.get("tests") {
|
||||
Some(Arg::Str(pattern)) => horizon_client::command::args::select_resources(
|
||||
self.ui.registries.ids("minecraft:test_instance"),
|
||||
pattern,
|
||||
)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let finder = Finder::Selection { tests, copies: 1 };
|
||||
let mut infos = Vec::new();
|
||||
for pos in self.finder_positions(&finder) {
|
||||
infos.extend(self.test_info_at(pos, Retry::none()));
|
||||
}
|
||||
for (key, def) in self.finder_tests(&finder) {
|
||||
infos.extend(self.test_info_for(key, def, 0, Retry::none())?);
|
||||
}
|
||||
self.game_tests.failed.clear();
|
||||
let batches = verify_batches(&infos, || self.game_tests.next_id());
|
||||
let grid = Grid::new(self.test_around(), 10).clearing();
|
||||
self.track_and_start_batches(batches, RunnerOptions::verify(grid));
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `TestCommand.reset`.
|
||||
fn reset_tests(&mut self, finder: &Finder) -> R<i32> {
|
||||
self.clear_game_tests();
|
||||
let mut count = 0;
|
||||
for pos in self.finder_positions(finder) {
|
||||
if self.test_info_at(pos).is_some() {
|
||||
if self.test_info_at(pos, Retry::none()).is_some() {
|
||||
// resetGameTestInfo: `TestInstanceBlockEntity.resetTest`.
|
||||
self.reset_test(pos);
|
||||
count += 1;
|
||||
@@ -302,6 +360,12 @@ impl Game<'_> {
|
||||
Some(Arg::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
// runWithRetryOptions: `RetryOptions(numberOfTimes, untilFailed)`, else
|
||||
// no retries.
|
||||
let retry = match int("numberOfTimes") {
|
||||
Some(n) => Retry::new(n, matches!(ctx.get("untilFailed"), Some(Arg::Bool(true)))),
|
||||
None => Retry::none(),
|
||||
};
|
||||
let has = |word: &str| ctx.literals.iter().any(|l| l == word);
|
||||
let looks = has("runthat") || has("resetthat") || has("clearthat");
|
||||
if looks {
|
||||
@@ -331,6 +395,11 @@ impl Game<'_> {
|
||||
let finder = Finder::Selection { tests, copies };
|
||||
self.run_tests(
|
||||
&finder,
|
||||
if has("runmultiple") {
|
||||
Retry::none()
|
||||
} else {
|
||||
retry
|
||||
},
|
||||
int("rotationSteps").unwrap_or(0),
|
||||
int("testsPerRow").unwrap_or(8),
|
||||
)
|
||||
@@ -348,6 +417,7 @@ impl Game<'_> {
|
||||
};
|
||||
self.run_tests(
|
||||
&finder,
|
||||
retry,
|
||||
int("rotationSteps").unwrap_or(0),
|
||||
int("testsPerRow").unwrap_or(8),
|
||||
)
|
||||
|
||||
@@ -1116,6 +1116,8 @@ impl Game<'_> {
|
||||
status["maps"] = json!(self.maps_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
// The pickup particles made so far, by what was taken.
|
||||
status["pickups"] = json!(self.pickups.made());
|
||||
// The world border this side knows of.
|
||||
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
|
||||
// The scoreboard sidebar the HUD shows.
|
||||
|
||||
@@ -68,17 +68,8 @@ pub(super) struct Creatures {
|
||||
pub shoulder_time: std::collections::HashMap<[i32; 4], i64>,
|
||||
/// The cat spawner's countdown (`CatSpawner.nextTick`).
|
||||
pub cat_tick: i32,
|
||||
/// Particles from a guest's turn, waiting for it to end
|
||||
/// (`lan_send_particles`: options, centre, spread, speed, count).
|
||||
pub lan_particles: Vec<(
|
||||
&'static str,
|
||||
horizon_client::particles::ParticleOptions,
|
||||
Option<horizon_world::registry::BlockStateId>,
|
||||
[f64; 3],
|
||||
[f64; 3],
|
||||
f64,
|
||||
i32,
|
||||
)>,
|
||||
/// Particles from a guest's turn, waiting for it to end.
|
||||
pub lan_particles: Vec<super::lan_particles::QueuedParticle>,
|
||||
}
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
@@ -987,7 +978,8 @@ impl Game<'_> {
|
||||
self.raider_died(m, slain_by_player);
|
||||
self.villager_murdered(m, slain_by_player, killer.flatten());
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
self.drop_death_loot(&mut dying, by_player, consumed, pos);
|
||||
let killer_entity = self.killer_loot_entity(killer.flatten());
|
||||
self.drop_death_loot(&mut dying, by_player, killer_entity, consumed, pos);
|
||||
self.create_wither_rose(m);
|
||||
if m.species() == Species::CopperGolem {
|
||||
// CopperGolem.dropEquipment: the preserved equipment too.
|
||||
@@ -1069,7 +1061,47 @@ impl Game<'_> {
|
||||
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
|
||||
/// world's random sequence, equipment by drop chance, species extras and
|
||||
/// the experience orbs, spawned in order.
|
||||
fn drop_death_loot(&mut self, m: &mut Mob, by_player: bool, consumed: bool, pos: [f64; 3]) {
|
||||
/// The killing player as a loot entity: their worn and held items, read
|
||||
/// from the guest who struck the last blow when a server's player did
|
||||
/// (on the server thread that is every player, the window's too), so
|
||||
/// Looting on their weapon counts.
|
||||
fn killer_loot_entity(&mut self, guest: Option<u32>) -> horizon_world::loot::LootEntity {
|
||||
let own = |g: &Self| {
|
||||
let mut e = horizon_world::loot::LootEntity::new("minecraft:player");
|
||||
let slots = [
|
||||
("mainhand", g.stack_in(g.selected)),
|
||||
("offhand", g.stack_in(super::shield::OFFHAND_SLOT)),
|
||||
("head", g.armor[0].as_ref()),
|
||||
("chest", g.armor[1].as_ref()),
|
||||
("legs", g.armor[2].as_ref()),
|
||||
("feet", g.armor[3].as_ref()),
|
||||
];
|
||||
for (slot, stack) in slots {
|
||||
if let Some(stack) = stack {
|
||||
e.equipment.push((slot.into(), to_loot(stack)));
|
||||
}
|
||||
}
|
||||
e
|
||||
};
|
||||
if let Some(conn) = guest.filter(|_| self.lan_acting.is_none())
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
let entity = self.as_guest(&mut host, conn, |g| own(g));
|
||||
self.lan = Some(host);
|
||||
if let Some(entity) = entity {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
own(self)
|
||||
}
|
||||
fn drop_death_loot(
|
||||
&mut self,
|
||||
m: &mut Mob,
|
||||
by_player: bool,
|
||||
killer: horizon_world::loot::LootEntity,
|
||||
consumed: bool,
|
||||
pos: [f64; 3],
|
||||
) {
|
||||
use horizon_client::mobs::{DamageKind, Who, drops};
|
||||
use horizon_world::loot::{LootDamage, LootEntity};
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(
|
||||
@@ -1078,13 +1110,7 @@ impl Game<'_> {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let player = || {
|
||||
let mut e = LootEntity::new("minecraft:player");
|
||||
if let Some(held) = self.held() {
|
||||
e.equipment.push(("mainhand".into(), to_loot(held)));
|
||||
}
|
||||
e
|
||||
};
|
||||
let player = || killer.clone();
|
||||
let damage = m.last_damage.map(|(d, _)| {
|
||||
let source = match d.attacker {
|
||||
Some(Who::Player) => Some(player()),
|
||||
@@ -1554,6 +1580,30 @@ impl Game<'_> {
|
||||
} => {
|
||||
self.mob_hits_mob(target, amount, source);
|
||||
}
|
||||
MobEvent::CubeHurtPlayer { cube, amount, from } => {
|
||||
// AbstractCubeMob.dealDamage: the attack sound (and its
|
||||
// two pitch draws, made even for a silent cube, whose
|
||||
// Entity.playSound is then skipped) only follows a hit
|
||||
// that landed.
|
||||
if self.hurt_player_from(
|
||||
amount,
|
||||
Some(from),
|
||||
"minecraft:mob_attack",
|
||||
Some(Some(cube)),
|
||||
) && let Some(m) = self.mobs.iter_mut().find(|m| m.id == cube)
|
||||
{
|
||||
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0;
|
||||
let at = m.center();
|
||||
if !m.silent {
|
||||
self.handle_mob_events(vec![MobEvent::Sound(
|
||||
"entity.slime.attack",
|
||||
at,
|
||||
1.0,
|
||||
pitch,
|
||||
)])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::CubeHurtMob {
|
||||
cube,
|
||||
target,
|
||||
@@ -2054,6 +2104,11 @@ impl Game<'_> {
|
||||
MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob),
|
||||
MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target),
|
||||
MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other),
|
||||
MobEvent::VillagerOffers { mob } => {
|
||||
if let Some(i) = self.mobs.iter().position(|m| m.id == mob) {
|
||||
self.ensure_offers(i);
|
||||
}
|
||||
}
|
||||
MobEvent::VillagerBirth {
|
||||
parent,
|
||||
partner,
|
||||
|
||||
@@ -708,8 +708,20 @@ impl Game<'_> {
|
||||
// The single-player host's state is not used: players have their own files.
|
||||
self.level.position = None;
|
||||
// DedicatedServer.initServer: worldData.setGameType; forceDifficulty.
|
||||
// `load_world` restored difficulty from level.dat; server.properties wins
|
||||
// (vanilla's forceDifficulty), including health_rules used for despawn (#4694).
|
||||
self.set_default_game_mode(game_mode);
|
||||
self.ui.difficulty = props.difficulty as u8;
|
||||
// MinecraftServer.setDifficulty: a hardcore world is always hard.
|
||||
self.ui.difficulty = if self.level.hardcore {
|
||||
3
|
||||
} else {
|
||||
props.difficulty as u8
|
||||
};
|
||||
self.level.set_difficulty(self.ui.difficulty);
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
// DedicatedServer.initServer: a legacy `announce-player-achievements`
|
||||
// sets the show_advancement_messages rule once, as the file is read.
|
||||
if let Some(announce) = props.announce_player_achievements {
|
||||
|
||||
@@ -934,6 +934,14 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the open dialog has a text input.
|
||||
pub(super) fn dialog_has_text_field(&self) -> bool {
|
||||
self.dialogs
|
||||
.open
|
||||
.as_ref()
|
||||
.is_some_and(|open| open.fields.iter().any(Option::is_some))
|
||||
}
|
||||
|
||||
/// `type TEXT` with a dialog open: into the focused text input, or the
|
||||
/// first one.
|
||||
pub(super) fn dialog_type(&mut self, text: &str) -> bool {
|
||||
|
||||
@@ -3344,6 +3344,9 @@ impl Game<'_> {
|
||||
/// The END stage: the portal crystals blow up (no block damage) and a
|
||||
/// new dragon takes over.
|
||||
fn finish_respawn(&mut self) {
|
||||
// Portal crystals are not on spikes; clear their revival beams before
|
||||
// the spike reset and before they are removed (vanilla resetEndCrystals).
|
||||
self.respawn_beams(None);
|
||||
self.reset_spike_crystals();
|
||||
self.fight.respawn = None;
|
||||
let list = std::mem::take(&mut self.fight.respawn_crystals);
|
||||
@@ -3398,6 +3401,7 @@ impl Game<'_> {
|
||||
/// `abortRespawnSequence`.
|
||||
fn abort_respawn(&mut self) {
|
||||
self.fight.respawn = None;
|
||||
self.respawn_beams(None);
|
||||
self.fight.respawn_crystals.clear();
|
||||
self.reset_spike_crystals();
|
||||
let _ = self.activate_exit_portal();
|
||||
|
||||
@@ -476,15 +476,15 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
// The players a server serves are sent them too.
|
||||
self.lan_send_particles(
|
||||
self.dim.id(),
|
||||
&options,
|
||||
None,
|
||||
self.lan_send_particles(super::lan_particles::QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options,
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
count as i32,
|
||||
);
|
||||
count: count as i32,
|
||||
});
|
||||
}
|
||||
/// `FishingHook.tick`.
|
||||
pub(super) fn tick_fishing(&mut self) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,9 @@
|
||||
//! golem. Players placing the pumpkin and dispensers share it.
|
||||
use super::Game;
|
||||
use horizon_client::Result;
|
||||
use horizon_client::block_pattern::{
|
||||
IRON_GOLEM_BASE, IRON_GOLEM_FULL, SNOW_GOLEM_BASE, SNOW_GOLEM_FULL,
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
fn golem_block(&self, p: [i32; 3]) -> &str {
|
||||
@@ -17,12 +20,12 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
/// The blocks of the golem completed at `head` and its spawn base
|
||||
/// (`BlockPattern.find`), snow golems first: the snow column ("^", "#",
|
||||
/// "#") spawns at its bottom, the iron T ("~^~", "###", "~#~") under its
|
||||
/// head. The blocks come column by column, top first, as
|
||||
/// `clearPatternBlocks` visits them. With `base`, the head's cell takes
|
||||
/// any block, as in the base patterns (" ", "#", "#") and ("~ ~", "###",
|
||||
/// "~#~") that `canSpawnGolem` checks before the head is there.
|
||||
/// (`BlockPattern.find`), snow golems first: the snow column
|
||||
/// (`SNOW_GOLEM_FULL`: "^", "#", "#") spawns at its bottom, the iron T
|
||||
/// (`IRON_GOLEM_FULL`: "~^~", "###", "~#~") under its head. The blocks
|
||||
/// come column by column, top first, as `clearPatternBlocks` visits them.
|
||||
/// With `base`, `SNOW_GOLEM_BASE` and `IRON_GOLEM_BASE` take any block
|
||||
/// where the head will go, as `canSpawnGolem` checks before it is there.
|
||||
fn find_golem(
|
||||
&self,
|
||||
head: [i32; 3],
|
||||
@@ -30,24 +33,24 @@ impl Game<'_> {
|
||||
) -> Option<(&'static str, Vec<[i32; 3]>, [i32; 3])> {
|
||||
let air = |p: [i32; 3]| self.res.facts().is_air(self.world.block(p[0], p[1], p[2]));
|
||||
let snow = |c: char, p: [i32; 3]| match c {
|
||||
'^' => base || self.is_golem_head(p),
|
||||
'^' => self.is_golem_head(p),
|
||||
_ => self.golem_block(p) == "minecraft:snow_block",
|
||||
};
|
||||
let iron = |c: char, p: [i32; 3]| match c {
|
||||
'^' => base || self.is_golem_head(p),
|
||||
'^' => self.is_golem_head(p),
|
||||
'#' => self.golem_block(p) == "minecraft:iron_block",
|
||||
_ => air(p),
|
||||
};
|
||||
if let Some(cell) = find_pattern(head, &["^", "#", "#"], snow) {
|
||||
let blocks = (0..3).map(|y| cell(0, y)).collect();
|
||||
return Some(("minecraft:snow_golem", blocks, cell(0, 2)));
|
||||
let (snow_pattern, iron_pattern) = if base {
|
||||
(SNOW_GOLEM_BASE, IRON_GOLEM_BASE)
|
||||
} else {
|
||||
(SNOW_GOLEM_FULL, IRON_GOLEM_FULL)
|
||||
};
|
||||
if let Some(m) = snow_pattern.find(head, snow) {
|
||||
return Some(("minecraft:snow_golem", m.front_cells(), m.block(0, 2, 0)));
|
||||
}
|
||||
let cell = find_pattern(head, &["~^~", "###", "~#~"], iron)?;
|
||||
let blocks = (0..3)
|
||||
.flat_map(|x| (0..3).map(move |y| (x, y)))
|
||||
.map(|(x, y)| cell(x, y))
|
||||
.collect();
|
||||
Some(("minecraft:iron_golem", blocks, cell(1, 2)))
|
||||
let m = iron_pattern.find(head, iron)?;
|
||||
Some(("minecraft:iron_golem", m.front_cells(), m.block(1, 2, 0)))
|
||||
}
|
||||
/// `CarvedPumpkinBlock.canSpawnGolem`: a snow or iron golem base, or a
|
||||
/// copper block (`#minecraft:copper`) below.
|
||||
@@ -254,78 +257,24 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockPattern.find` for a one-aisle pattern (`rows`, top first): the
|
||||
/// pattern's corner at every block from `pos` to `pos + size - 1` (x fastest,
|
||||
/// then y, then z), turned every way (`forwards`, then `up`, in `Direction`
|
||||
/// order, never along each other), until every cell passes `test`. The match
|
||||
/// gives the block of column x, row y (`BlockPatternMatch.getBlock(x, y, 0)`).
|
||||
fn find_pattern(
|
||||
pos: [i32; 3],
|
||||
rows: &[&str],
|
||||
test: impl Fn(char, [i32; 3]) -> bool,
|
||||
) -> Option<impl Fn(i32, i32) -> [i32; 3]> {
|
||||
// Direction.values(): down, up, north, south, west, east.
|
||||
const DIRECTIONS: [[i32; 3]; 6] = [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
];
|
||||
let size = rows.len().max(rows[0].len()) as i32;
|
||||
let corners =
|
||||
(0..size).flat_map(|z| (0..size).flat_map(move |y| (0..size).map(move |x| [x, y, z])));
|
||||
for d in corners {
|
||||
let origin = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
|
||||
for forwards in DIRECTIONS {
|
||||
for up in DIRECTIONS {
|
||||
if up == forwards || up == forwards.map(|v| -v) {
|
||||
continue;
|
||||
}
|
||||
// translateAndRotate: right is forwards × up, rows go down `up`.
|
||||
let right = [
|
||||
forwards[1] * up[2] - forwards[2] * up[1],
|
||||
forwards[2] * up[0] - forwards[0] * up[2],
|
||||
forwards[0] * up[1] - forwards[1] * up[0],
|
||||
];
|
||||
let cell =
|
||||
move |x: i32, y: i32| [0, 1, 2].map(|i| origin[i] - up[i] * y + right[i] * x);
|
||||
let matches = rows.iter().enumerate().all(|(y, row)| {
|
||||
row.chars()
|
||||
.enumerate()
|
||||
.all(|(x, c)| test(c, cell(x as i32, y as i32)))
|
||||
});
|
||||
if matches {
|
||||
return Some(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::find_pattern;
|
||||
use horizon_client::block_pattern::{
|
||||
BlockPattern, IRON_GOLEM_BASE, IRON_GOLEM_FULL, SNOW_GOLEM_BASE, SNOW_GOLEM_FULL,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const SNOW: [&str; 3] = ["^", "#", "#"];
|
||||
const IRON: [&str; 3] = ["~^~", "###", "~#~"];
|
||||
|
||||
/// `find_pattern` over `blocks` (anything else is air): the cells of the
|
||||
/// match, column by column, top first.
|
||||
fn find(rows: &[&str], head: [i32; 3], blocks: &[([i32; 3], char)]) -> Option<Vec<[i32; 3]>> {
|
||||
/// `BlockPattern.find` over `blocks` (anything else is air): the cells of
|
||||
/// the match, column by column, top first.
|
||||
fn find(
|
||||
pattern: BlockPattern,
|
||||
head: [i32; 3],
|
||||
blocks: &[([i32; 3], char)],
|
||||
) -> Option<Vec<[i32; 3]>> {
|
||||
let world: HashMap<_, _> = blocks.iter().copied().collect();
|
||||
let at = |p: [i32; 3]| world.get(&p).copied().unwrap_or('~');
|
||||
let cell = find_pattern(head, rows, |c, p| at(p) == c)?;
|
||||
let width = rows[0].len() as i32;
|
||||
Some(
|
||||
(0..width)
|
||||
.flat_map(|x| (0..rows.len() as i32).map(move |y| (x, y)))
|
||||
.map(|(x, y)| cell(x, y))
|
||||
.collect(),
|
||||
)
|
||||
let m = pattern.find(head, |c, p| at(p) == c)?;
|
||||
Some(m.front_cells())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -342,7 +291,7 @@ mod tests {
|
||||
let at = |n: i32| [0, 1, 2].map(|i| head[i] + step[i] * n);
|
||||
let blocks = [(at(0), '^'), (at(1), '#'), (at(2), '#')];
|
||||
assert_eq!(
|
||||
find(&SNOW, head, &blocks),
|
||||
find(SNOW_GOLEM_FULL, head, &blocks),
|
||||
Some(vec![at(0), at(1), at(2)]),
|
||||
"snow blocks going {step:?} from the head"
|
||||
);
|
||||
@@ -353,8 +302,11 @@ mod tests {
|
||||
fn a_bent_or_short_snow_column_is_no_golem() {
|
||||
let head = [0, 0, 0];
|
||||
let bent = [(head, '^'), ([0, -1, 0], '#'), ([1, -1, 0], '#')];
|
||||
assert_eq!(find(&SNOW, head, &bent), None);
|
||||
assert_eq!(find(&SNOW, head, &[(head, '^'), ([0, -1, 0], '#')]), None);
|
||||
assert_eq!(find(SNOW_GOLEM_FULL, head, &bent), None);
|
||||
assert_eq!(
|
||||
find(SNOW_GOLEM_FULL, head, &[(head, '^'), ([0, -1, 0], '#')]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -368,15 +320,76 @@ mod tests {
|
||||
([1, 0, 1], '#'),
|
||||
([2, 0, 0], '#'),
|
||||
];
|
||||
let cells = find(&IRON, head, &blocks).expect("a lying iron golem");
|
||||
let cells = find(IRON_GOLEM_FULL, head, &blocks).expect("a lying iron golem");
|
||||
// Column 1, rows 0 to 2: the head, the body and the legs.
|
||||
assert_eq!(&cells[3..6], &[[0, 0, 0], [1, 0, 0], [2, 0, 0]]);
|
||||
let mut arms_out = blocks.to_vec();
|
||||
arms_out.push(([2, 0, 1], '#'));
|
||||
assert_eq!(
|
||||
find(&IRON, head, &arms_out),
|
||||
find(IRON_GOLEM_FULL, head, &arms_out),
|
||||
None,
|
||||
"a block beside the legs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_base_patterns_ignore_the_head() {
|
||||
// canSpawnGolem checks the base patterns before the head is there: the
|
||||
// head's cell (' ' in the pattern) takes any block.
|
||||
let snow = [([0, -1, 0], '#'), ([0, -2, 0], '#')];
|
||||
assert_eq!(
|
||||
find(SNOW_GOLEM_BASE, [0, 0, 0], &snow),
|
||||
Some(vec![[0, 0, 0], [0, -1, 0], [0, -2, 0]])
|
||||
);
|
||||
assert_eq!(find(SNOW_GOLEM_FULL, [0, 0, 0], &snow), None);
|
||||
let iron = [
|
||||
([-1, 0, 0], '#'),
|
||||
([0, 0, 0], '#'),
|
||||
([1, 0, 0], '#'),
|
||||
([0, -1, 0], '#'),
|
||||
];
|
||||
assert!(find(IRON_GOLEM_BASE, [0, 1, 0], &iron).is_some());
|
||||
assert_eq!(find(IRON_GOLEM_FULL, [0, 1, 0], &iron), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_corner_of_the_search_cube_wins() {
|
||||
// There is no snow at the head, so the corners x fastest, then y, then
|
||||
// z decide: the column one step along +x is the one taken.
|
||||
let column = |c: [i32; 3]| {
|
||||
[
|
||||
(c, '^'),
|
||||
([c[0], c[1] - 1, c[2]], '#'),
|
||||
([c[0], c[1] - 2, c[2]], '#'),
|
||||
]
|
||||
};
|
||||
let blocks: Vec<_> = [column([1, 0, 0]), column([0, 1, 0]), column([0, 0, 1])].concat();
|
||||
assert_eq!(
|
||||
find(SNOW_GOLEM_FULL, [0, 0, 0], &blocks),
|
||||
Some(vec![[1, 0, 0], [1, -1, 0], [1, -2, 0]])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwards_then_up_in_direction_order_picks_the_column() {
|
||||
// One head with a snow block below it and along both z ways. Every
|
||||
// `up` is tried for one `forwards` before the next forwards, in
|
||||
// Direction order and never along `forwards`: down's first usable up
|
||||
// is north, so the +z column is the match, not the column below (up,
|
||||
// under forwards north) nor the one along -z (south, later).
|
||||
let head = [0, 0, 0];
|
||||
let blocks = [
|
||||
(head, '^'),
|
||||
([0, -1, 0], '#'),
|
||||
([0, -2, 0], '#'),
|
||||
([0, 0, 1], '#'),
|
||||
([0, 0, 2], '#'),
|
||||
([0, 0, -1], '#'),
|
||||
([0, 0, -2], '#'),
|
||||
];
|
||||
assert_eq!(
|
||||
find(SNOW_GOLEM_FULL, head, &blocks),
|
||||
Some(vec![[0, 0, 0], [0, 0, 1], [0, 0, 2]])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,7 @@ impl Game<'_> {
|
||||
});
|
||||
self.clicks.destroying = destroying;
|
||||
self.attack_clicks = 0;
|
||||
self.use_clicks = 0;
|
||||
self.request_remote_mining_stop();
|
||||
self.mining = None;
|
||||
}
|
||||
@@ -203,12 +204,18 @@ impl Game<'_> {
|
||||
/// attack clicks since the last tick and the held attack button.
|
||||
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
|
||||
let attack_clicks = std::mem::take(&mut self.attack_clicks);
|
||||
let use_clicks = std::mem::take(&mut self.use_clicks);
|
||||
if self.screen == Screen::Playing && self.title.is_none() && self.sleeping.is_none() {
|
||||
let mut clicks = std::mem::take(&mut self.clicks);
|
||||
// keyAttack.isDown(); playing implies the grabbed mouse here
|
||||
// (scripts under --control hold buttons without grabbing it).
|
||||
let down = self.left;
|
||||
let ticks = self.clicks.destroying.ticks;
|
||||
// Minecraft.hitResult is picked once per frame: a creative break
|
||||
// does not move the use that follows it past the broken block.
|
||||
if use_clicks > 0 && attack_clicks > 0 {
|
||||
self.tick_hit = Some(self.pick());
|
||||
}
|
||||
let mut world = GameHands {
|
||||
game: self,
|
||||
error: None,
|
||||
@@ -217,8 +224,24 @@ impl Game<'_> {
|
||||
let error = world.error.take();
|
||||
self.clicks = clicks;
|
||||
if let Some(e) = error {
|
||||
self.tick_hit = None;
|
||||
return Err(e);
|
||||
}
|
||||
// Use clicks follow the attack clicks within the tick, against
|
||||
// the block the attack may just have broken.
|
||||
let mut used = Ok(());
|
||||
for _ in 0..use_clicks {
|
||||
if self.using.is_some() {
|
||||
break;
|
||||
}
|
||||
used = self.use_with_hand();
|
||||
self.use_delay = 4;
|
||||
if used.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.tick_hit = None;
|
||||
used?;
|
||||
// A tick of breaking: the hit sound every fourth tick and the
|
||||
// cracking particles.
|
||||
let d = &self.clicks.destroying;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Input method (IME) text: committed composition goes to the focused text
|
||||
//! field through the same sinks the keyboard and `horizon-ctl type` use.
|
||||
//! The window only asks the OS for an input method while such a field has
|
||||
//! focus, so movement keys keep arriving as plain key events.
|
||||
use super::{Game, Screen, books::BookScreen};
|
||||
use horizon_client::{
|
||||
hud::HudBatch,
|
||||
preedit::{self, PreeditEvent},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether a text box has focus right now (the fields `ime_commit` can fill).
|
||||
pub(super) fn text_field_focused(&mut self) -> bool {
|
||||
if self.dialog_open() {
|
||||
return self.dialog_has_text_field();
|
||||
}
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
return title.focused_field().is_some();
|
||||
}
|
||||
if self.sign_edit.is_some() || matches!(self.book, Some(BookScreen::Edit { .. })) {
|
||||
return true;
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat | Screen::CommandBlock => true,
|
||||
Screen::WorldOptions => self.world_options_typing(),
|
||||
Screen::Creative => self.creative_screen.tab().search,
|
||||
Screen::Inventory => {
|
||||
self.mod_browser_typing() || self.recipe_search_focused() || self.station_typing()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow the input method only while a text box has focus.
|
||||
pub(super) fn sync_ime(&mut self) {
|
||||
let want = self.text_field_focused();
|
||||
if want != self.ime_allowed
|
||||
&& let Some(window) = &self.window
|
||||
{
|
||||
window.set_ime_allowed(want);
|
||||
self.ime_allowed = want;
|
||||
// The field lost the focus (or a new one took it): the composition
|
||||
// and the candidate window's place belong to the old one.
|
||||
self.ime_area = None;
|
||||
if !want {
|
||||
preedit::set_preedit(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `WindowEvent::Ime(Preedit)`: `KeyboardHandler.textEditing`. The text
|
||||
/// still being composed is shown under the focused field's caret.
|
||||
pub(super) fn ime_preedit(&mut self, text: &str, cursor: Option<(usize, usize)>) {
|
||||
preedit::set_preedit(
|
||||
self.text_field_focused()
|
||||
.then(|| PreeditEvent::from_winit(text, cursor))
|
||||
.flatten(),
|
||||
);
|
||||
}
|
||||
|
||||
/// With the frame's GUI built: draw the composition over it and move the
|
||||
/// input method's candidate window to the caret
|
||||
/// (`TextInputManager.setTextInputArea`, in window pixels).
|
||||
pub(super) fn flush_ime(&mut self, hud: &mut HudBatch) {
|
||||
preedit::draw_overlay(hud, &self.gui.atlas);
|
||||
let Some([x0, y0, x1, y1]) = preedit::take_text_input_area() else {
|
||||
return;
|
||||
};
|
||||
let scale = hud.scale;
|
||||
let x = (x0 * scale).round() as i32;
|
||||
let y = (y0 * scale).round() as i32;
|
||||
let width = (((x1 - x0) * scale).round() as i32).max(1);
|
||||
let height = (((y1 - y0) * scale).round() as i32).max(1);
|
||||
let area = [x, y, width, height];
|
||||
if self.ime_area == Some(area) {
|
||||
return;
|
||||
}
|
||||
if let Some(window) = &self.window
|
||||
&& self.ime_allowed
|
||||
{
|
||||
window.set_ime_cursor_area(
|
||||
winit::dpi::PhysicalPosition::new(x, y),
|
||||
winit::dpi::PhysicalSize::new(width as u32, height as u32),
|
||||
);
|
||||
self.ime_area = Some(area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text the input method committed (`WindowEvent::Ime(Commit)`).
|
||||
pub(super) fn ime_commit(&mut self, text: &str) {
|
||||
preedit::set_preedit(None);
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
if text.is_empty() || !self.text_field_focused() {
|
||||
return;
|
||||
}
|
||||
if self.dialog_type(&text) {
|
||||
return;
|
||||
}
|
||||
if self.title.is_some() {
|
||||
self.title_edit(Some(&text), false);
|
||||
} else if self.book.is_some() {
|
||||
self.book_input(None, Some(&text));
|
||||
} else if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(&text));
|
||||
} else {
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(&text),
|
||||
Screen::CommandBlock => self.command_block_type(&text),
|
||||
Screen::WorldOptions => self.world_options_text(Some(&text)),
|
||||
Screen::Creative => self.creative_search_edit(Some(&text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(Some(&text), false)
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(&text), false)
|
||||
}
|
||||
Screen::Inventory => self.station_text(Some(&text), false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use horizon_client::{
|
||||
jigsaw_block::{self, JigsawBlock, JointType},
|
||||
};
|
||||
use horizon_protocol::packets::play;
|
||||
use horizon_world::worldgen::generator::JigsawQuery;
|
||||
use horizon_world::{nbt::Tag, worldgen::generator::JigsawQuery};
|
||||
|
||||
type Pos = (i32, i32, i32);
|
||||
|
||||
@@ -128,11 +128,14 @@ impl Game<'_> {
|
||||
position,
|
||||
};
|
||||
let keep = p.keep_jigsaws;
|
||||
self.with_level(|b, level, ticks| {
|
||||
let (_, entities) = self.with_level(|b, level, ticks| {
|
||||
b.place_jigsaw(level, ticks, position, keep, || {
|
||||
terrain.jigsaw_pieces(&query)
|
||||
})
|
||||
});
|
||||
for entity in entities {
|
||||
self.spawn_entity_from_nbt(Tag::Compound(entity));
|
||||
}
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! server threads and the play-state hub, feeds it the world's entities each
|
||||
//! tick and turns what the guests do into gameplay. A world that is not open
|
||||
//! costs one `Option` check per tick.
|
||||
use super::{Game, shield::OFFHAND_SLOT};
|
||||
use super::{Game, shield::OFFHAND_SLOT, shoulders::shoulder_variants};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
@@ -635,12 +635,26 @@ fn mob_meta(
|
||||
));
|
||||
}
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE.
|
||||
// AbstractCubeMob.ID_SIZE; SulfurCube also syncs FROM_BUCKET and
|
||||
// MAX_FUSE (clients tick the fuse for the swell / white flash).
|
||||
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
|
||||
if e.size > 1 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.size)));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(1)));
|
||||
if kind == "minecraft:sulfur_cube" {
|
||||
let from_bucket = e.scratch[3] != 0;
|
||||
if from_bucket {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
// SulfurCube.MAX_FUSE: set when primed; 0 means unprimed.
|
||||
let fuse = horizon_client::mobs::sulfur_cube::fuse(m);
|
||||
if fuse >= 0 {
|
||||
meta.push((20, serializer::INT, Value::Int(fuse)));
|
||||
}
|
||||
defaults.push((20, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
}
|
||||
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the
|
||||
// guest swells the creeper from them. The direction always goes in,
|
||||
@@ -1020,6 +1034,28 @@ pub const HELP: &[&str] = &[
|
||||
"lan status the hosting state and the guests",
|
||||
];
|
||||
|
||||
/// With the game mode forced, the guests online take the default mode
|
||||
/// (setting a mode does nothing when it is already theirs). A world open to
|
||||
/// LAN is an integrated server, which leaves out the players who have game
|
||||
/// master permission; a dedicated server does not.
|
||||
fn enforce_guest_game_mode(host: &mut LanHost) {
|
||||
if !host.hub.config.force_game_mode {
|
||||
return;
|
||||
}
|
||||
let mode = host.hub.config.default_game_mode;
|
||||
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
|
||||
let stale: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.game_mode != mode && !(spares_operators && g.permission >= 2))
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in stale {
|
||||
host.hub.set_game_mode(conn, mode);
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `horizon-ctl lan ...`.
|
||||
pub(super) fn lan_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
@@ -1452,6 +1488,9 @@ impl Game<'_> {
|
||||
self.level.creative = mode == 1;
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.config.default_game_mode = mode;
|
||||
// `setWorldGameType` and `DefaultGameModeCommands.setMode`: a
|
||||
// world that forces its mode puts the guests online in it.
|
||||
enforce_guest_game_mode(host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1469,25 +1508,7 @@ impl Game<'_> {
|
||||
host.settings.force_game_mode = force;
|
||||
host.hub.config.guest_commands = effective;
|
||||
host.hub.config.force_game_mode = force;
|
||||
// MinecraftServer.enforceGameTypeForPlayers: the guests online
|
||||
// take the default mode (`setGameMode` does nothing when it is
|
||||
// already theirs). A world open to LAN is an IntegratedServer,
|
||||
// whose `updateGameModeBasedOnPermission` leaves out the players
|
||||
// who have game master permission; a dedicated server does not.
|
||||
if force {
|
||||
let mode = host.hub.config.default_game_mode;
|
||||
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
|
||||
let stale: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.game_mode != mode && !(spares_operators && g.permission >= 2))
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in stale {
|
||||
host.hub.set_game_mode(conn, mode);
|
||||
}
|
||||
}
|
||||
enforce_guest_game_mode(host);
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
}
|
||||
@@ -1532,6 +1553,7 @@ impl Game<'_> {
|
||||
living_flags: living_flags(self.using)
|
||||
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
|
||||
stuck: (self.combat.stuck.arrows, self.combat.stuck.stingers),
|
||||
shoulders: shoulder_variants(self.level.player_extra.as_ref()),
|
||||
bed: self.sleeping,
|
||||
body: BodyFlags {
|
||||
gliding: p.fall_flying,
|
||||
@@ -2218,19 +2240,27 @@ impl Game<'_> {
|
||||
let players: Vec<_> = host_player
|
||||
.into_iter()
|
||||
.chain(guests.filter_map(|g| g.motion.as_deref().map(|m| (g.entity_id, g.position, m))))
|
||||
.map(|(id, at, p)| (id, at, p.health.hurt_animation_time, p.hurt_log.last()))
|
||||
.map(|(id, at, p)| {
|
||||
(
|
||||
id,
|
||||
at,
|
||||
p.health.hurt_animation_time,
|
||||
p.health.dead_or_dying(),
|
||||
p.hurt_log.last(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (id, at, hurt, record) in players {
|
||||
for (id, at, hurt, dead, record) in players {
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&(id as u32)));
|
||||
if hurt > old.map_or(0, |o| o.0) {
|
||||
if hurt > old.map_or(0, |o| o.0) && !dead {
|
||||
// Starving keeps no record: any type flashes the same.
|
||||
let kind = record.map_or("minecraft:generic", |r| r.source.as_str());
|
||||
host.hub.damage_event(id, kind, None, None, at);
|
||||
}
|
||||
seen.insert(id as u32, (hurt, false, None));
|
||||
seen.insert(id as u32, (hurt, dead, None));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
|
||||
@@ -2280,6 +2310,13 @@ impl Game<'_> {
|
||||
// then ticks without a player.
|
||||
.map(|chunk| (chunk, 1)),
|
||||
)
|
||||
// A portal ticket keeps the area around the exit loaded and
|
||||
// ticking (its entity-ticking chunks and their neighbours).
|
||||
.chain(
|
||||
self.portal_ticket_chunks(self.dim)
|
||||
.into_iter()
|
||||
.map(|chunk| (chunk, 1)),
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
|
||||
@@ -2363,6 +2400,7 @@ impl Game<'_> {
|
||||
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
|
||||
m.extra.auto_spin_attack = body.spin_attack;
|
||||
(m.extra.arrow_count, m.extra.stinger_count) = g.stuck;
|
||||
m.extra.shoulder_parrots = g.shoulders;
|
||||
m.velocity = g
|
||||
.motion
|
||||
.as_ref()
|
||||
|
||||
@@ -1506,13 +1506,7 @@ impl Game<'_> {
|
||||
let Some(stack) = self.item_from_network(host, &p.item) else {
|
||||
return;
|
||||
};
|
||||
if stack.count
|
||||
> self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(64, |i| i.max_stack)
|
||||
{
|
||||
if !self.creative_stack_valid(host, gi, &stack) {
|
||||
return;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
@@ -1527,11 +1521,18 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |g| g.player_drop(stack));
|
||||
}
|
||||
S::SetCreativeModeSlot(p) => {
|
||||
// Slot 0 is the crafting result: not a slot to set.
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if !g.creative() || !(0..46).contains(&p.slot) {
|
||||
if !g.creative() || !(1..46).contains(&p.slot) {
|
||||
return;
|
||||
}
|
||||
let item = self.item_from_network(host, &p.item);
|
||||
if item
|
||||
.as_ref()
|
||||
.is_some_and(|s| !self.creative_stack_valid(host, gi, s))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let slot = p.slot as usize;
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let old = g.menu_slot(slot);
|
||||
@@ -1595,7 +1596,14 @@ impl Game<'_> {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::SelectBundleItem(p) => {
|
||||
if !spectator && let Ok(slot) = usize::try_from(p.slot) {
|
||||
// ServerGamePacketListenerImpl.handleBundleItemSelectedPacket: the slot
|
||||
// is the open menu's (#4436).
|
||||
let own = Self::lan_crafting_table_own_slots(host, conn)
|
||||
.or_else(|| Self::lan_station_own_slots(host, conn));
|
||||
let slot = usize::try_from(p.slot)
|
||||
.ok()
|
||||
.and_then(|slot| bundle_inventory_slot(slot, own));
|
||||
if !spectator && let Some(slot) = slot {
|
||||
host.hub
|
||||
.select_bundle_item(conn, slot, p.index, &mut self.world);
|
||||
}
|
||||
@@ -1875,6 +1883,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
|
||||
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
|
||||
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
|
||||
/// handleSetCreativeModeSlot's `validData` then ignores a stack beyond its own stack size.
|
||||
fn creative_stack_valid(&self, host: &mut LanHost, gi: usize, s: &ItemStack) -> bool {
|
||||
if s.count > 99 {
|
||||
let g = &host.hub.guests[gi];
|
||||
let conn = g.conn;
|
||||
let reason = format!("Invalid packet: count {} is not in 1..99", s.count);
|
||||
eprintln!("{} sent an invalid creative stack: {reason}", g.name);
|
||||
host.hub
|
||||
.kick(conn, horizon_protocol::text::literal(&reason));
|
||||
return false;
|
||||
}
|
||||
s.count <= self.stack_max_size(s)
|
||||
}
|
||||
|
||||
fn item_from_network(
|
||||
&self,
|
||||
host: &LanHost,
|
||||
@@ -2501,7 +2526,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
// restartClientLoadTimerAfterRespawn.
|
||||
g.dead = false;
|
||||
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
|
||||
g.death_time = 0;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
@@ -3059,9 +3086,13 @@ impl Game<'_> {
|
||||
// LivingEntity.aiStep: a Riptide spin counts down on the server's
|
||||
// copy too, or it would stay in the spin pose (eye 0.4) for good.
|
||||
// LivingEntity.tick: a changed scale rebuilds the box. Player.tick
|
||||
// ends with updatePlayerPose: a swimmer, or a player crawling
|
||||
// under a block, takes the short box its moves are checked with.
|
||||
let _ = w.server_player_pose_tick();
|
||||
// ends with updatePlayerPose — or, for a passenger, the rideTick
|
||||
// path that clears a swim (#4509 / #3308).
|
||||
let _ = if riding {
|
||||
w.passenger_pose_tick()
|
||||
} else {
|
||||
w.server_player_pose_tick()
|
||||
};
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
|
||||
for event in &events {
|
||||
@@ -3804,6 +3835,7 @@ impl Game<'_> {
|
||||
self.combat.stuck.tick();
|
||||
}
|
||||
for g in &mut host.hub.guests {
|
||||
g.shoulders = super::shoulders::shoulder_variants(g.saved.as_ref());
|
||||
g.stuck = match self.guest_combat.get_mut(&g.conn) {
|
||||
Some(combat) => {
|
||||
if !paused {
|
||||
@@ -3954,3 +3986,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ServerboundSelectBundleItemPacket` slot as the hub's `select_bundle_item` reads it.
|
||||
/// With a crafting table or a station open (`own` slots of its own first), its
|
||||
/// inventory part is the inventory menu's main inventory and hotbar from slot 9, in
|
||||
/// the same order; its own slots hold no bundle the hub can reach.
|
||||
fn bundle_inventory_slot(slot: usize, own: Option<usize>) -> Option<usize> {
|
||||
match own {
|
||||
Some(own) => slot.checked_sub(own).filter(|s| *s < 36).map(|s| s + 9),
|
||||
None => Some(slot),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bundle_slot_tests {
|
||||
use super::bundle_inventory_slot;
|
||||
|
||||
/// A crafting table (10 slots of its own) and an enchanting table (2): the first
|
||||
/// inventory slot and the last hotbar slot map to 9 and 44; their own slots to none.
|
||||
#[test]
|
||||
fn station_menus_map_to_the_inventory_menu() {
|
||||
assert_eq!(bundle_inventory_slot(10, Some(10)), Some(9));
|
||||
assert_eq!(bundle_inventory_slot(45, Some(10)), Some(44));
|
||||
assert_eq!(bundle_inventory_slot(2, Some(2)), Some(9));
|
||||
assert_eq!(bundle_inventory_slot(5, Some(10)), None);
|
||||
assert_eq!(bundle_inventory_slot(46, Some(10)), None);
|
||||
assert_eq!(bundle_inventory_slot(45, None), Some(45));
|
||||
}
|
||||
}
|
||||
@@ -583,6 +583,13 @@ impl Game<'_> {
|
||||
/// (`window`; None closes the table whichever it is, and 0 the inventory
|
||||
/// menu) gives its grid back to the inventory, what doesn't fit is
|
||||
/// dropped. False when no such window is open.
|
||||
/// The open crafting table's own slots (result and grid) before the
|
||||
/// player's inventory, if the guest has one open.
|
||||
pub(super) fn lan_crafting_table_own_slots(host: &LanHost, conn: u32) -> Option<usize> {
|
||||
let (_, _, menu, _) = host.play.get(&conn)?.crafting.table.as_ref()?;
|
||||
Some(menu.slot_count() - 36)
|
||||
}
|
||||
|
||||
pub(super) fn lan_crafting_closed(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
|
||||
@@ -95,6 +95,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
MobEvent::AttackPlayer(..)
|
||||
| MobEvent::CubeHurtPlayer { .. }
|
||||
| MobEvent::AttackPlayerWithEffect { .. }
|
||||
| MobEvent::StingPlayer { .. }
|
||||
| MobEvent::DamagePlayer { .. }
|
||||
|
||||
@@ -11,6 +11,17 @@ use horizon_protocol::{
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// A particle request awaiting delivery to players in its level.
|
||||
pub(super) struct QueuedParticle {
|
||||
pub dimension: &'static str,
|
||||
pub options: ParticleOptions,
|
||||
pub state: Option<BlockStateId>,
|
||||
pub at: [f64; 3],
|
||||
pub spread: [f64; 3],
|
||||
pub speed: f64,
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
/// An ARGB colour as the options' RGBA.
|
||||
fn rgba(argb: i32) -> [f32; 4] {
|
||||
let c = |shift: i32| ((argb >> shift) & 255) as f32 / 255.0;
|
||||
@@ -367,32 +378,23 @@ impl Game<'_> {
|
||||
/// the players (a guest's turn through `as_guest`, a host's hit on a
|
||||
/// guest) the packet waits for the turn to end so that it reaches every
|
||||
/// player, the guest too.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn lan_send_particles(
|
||||
&mut self,
|
||||
dimension: &'static str,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: i32,
|
||||
) {
|
||||
pub(super) fn lan_send_particles(&mut self, request: QueuedParticle) {
|
||||
if self.lan.is_none() {
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.creatures.lan_particles.push((
|
||||
dimension,
|
||||
options.clone(),
|
||||
state,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
count,
|
||||
));
|
||||
self.creatures.lan_particles.push(request);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(particle) = self.network_particle(options, state) else {
|
||||
let QueuedParticle {
|
||||
dimension,
|
||||
options,
|
||||
state,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
count,
|
||||
} = request;
|
||||
let Some(particle) = self.network_particle(&options, state) else {
|
||||
return;
|
||||
};
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
@@ -428,10 +430,8 @@ impl Game<'_> {
|
||||
|
||||
/// The particles guests' turns put off.
|
||||
pub(super) fn flush_lan_particles(&mut self) {
|
||||
for (dimension, options, state, at, spread, speed, count) in
|
||||
std::mem::take(&mut self.creatures.lan_particles)
|
||||
{
|
||||
self.lan_send_particles(dimension, &options, state, at, spread, speed, count);
|
||||
for request in std::mem::take(&mut self.creatures.lan_particles) {
|
||||
self.lan_send_particles(request);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -989,6 +989,13 @@ impl Game<'_> {
|
||||
self.world_options_text(event.text.as_deref());
|
||||
}
|
||||
}
|
||||
/// Whether a World Options text box has focus (the port or a game rule box).
|
||||
pub(super) fn world_options_typing(&self) -> bool {
|
||||
self.world_options.as_ref().is_some_and(|o| {
|
||||
o.rules.as_ref().is_some_and(|r| r.typing())
|
||||
|| (o.port.focused && o.view == View::Options)
|
||||
})
|
||||
}
|
||||
/// Digits typed into the port field (the keyboard, or a controller's
|
||||
/// on-screen keyboard), and its backspace.
|
||||
pub(super) fn world_options_text(&mut self, text: Option<&str>) {
|
||||
|
||||
@@ -141,6 +141,13 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// How many slots of its own the guest's open station menu has before the
|
||||
/// player's inventory (`slot_count - 36`), if one is open.
|
||||
pub(super) fn lan_station_own_slots(host: &LanHost, conn: u32) -> Option<usize> {
|
||||
let gs = host.play.get(&conn)?.station.as_ref()?;
|
||||
Some(gs.menu.slot_count() - 36)
|
||||
}
|
||||
|
||||
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
|
||||
/// block entity, with its book and page.
|
||||
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
|
||||
|
||||
@@ -270,7 +270,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let g = &host.hub.guests[gi];
|
||||
if g.awaiting_teleport.is_some() || !g.loaded {
|
||||
if g.awaiting_teleport.is_some() || !g.client_loaded() {
|
||||
return;
|
||||
}
|
||||
let owner = host.hub.singleplayer_owner == Some(g.uuid);
|
||||
|
||||
@@ -132,6 +132,13 @@ impl Levels {
|
||||
}
|
||||
|
||||
/// The levels that are not in place, by dimension.
|
||||
/// The forced chunks of every level not in place, released.
|
||||
pub(super) fn release_parked_forced_chunks(&mut self) {
|
||||
for level in self.parked.values_mut() {
|
||||
level.streaming.forced.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parked_dims(&self) -> Vec<Dim> {
|
||||
self.parked.keys().copied().collect()
|
||||
}
|
||||
@@ -325,6 +332,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Far terrain for a client in `dim`: the level in place may be another
|
||||
/// one, the server ticking its levels in turn.
|
||||
pub(super) fn lod_source_in(
|
||||
&self,
|
||||
dim: Dim,
|
||||
) -> Option<Arc<dyn horizon_client::streaming::LodSource>> {
|
||||
if dim == self.dim {
|
||||
return Some(self.streaming.lod_source());
|
||||
}
|
||||
let parked = self.levels.parked.get(&dim)?;
|
||||
Some(parked.streaming.lod_source())
|
||||
}
|
||||
|
||||
/// Put `dim`'s level in place for a command whose source is there
|
||||
/// (`execute in`, `execute at` a player in another level), opening it if
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
@@ -503,10 +523,15 @@ impl Game<'_> {
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
|| self.player_bed_levels().any(|b| b == *d)
|
||||
|| forced
|
||||
// A game test runs in its own level, players or not.
|
||||
|| self.game_tests.has_tests_in(*d)
|
||||
|| self.pregen_level() == Some(*d)
|
||||
// A pending portal arrival keeps its destination level
|
||||
// running so it can land and tick (the portal ticket).
|
||||
|| !self.arrival_chunks(*d).is_empty()
|
||||
// A portal ticket keeps its dimension active
|
||||
// (`TicketType.shouldKeepDimensionActive`).
|
||||
|| !self.portal_ticket_chunks(*d).is_empty()
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
@@ -515,6 +540,18 @@ impl Game<'_> {
|
||||
dims
|
||||
}
|
||||
|
||||
/// The chunks holding a portal ticket in `dim`'s level (`placePortalTicket`).
|
||||
pub(super) fn portal_ticket_chunks(&self, dim: Dim) -> Vec<(i32, i32)> {
|
||||
if dim == self.dim {
|
||||
self.tickets.portal_chunks().collect()
|
||||
} else {
|
||||
self.levels
|
||||
.parked
|
||||
.get(&dim)
|
||||
.map_or_else(Vec::new, |p| p.tickets.portal_chunks().collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the parked levels' empty ticks, and save and close the ones
|
||||
/// (other than the Overworld) nobody has been in for a while.
|
||||
pub(super) fn close_idle_levels(&mut self) -> Result<()> {
|
||||
|
||||
@@ -960,6 +960,12 @@ impl Game<'_> {
|
||||
self.carts.remove(i);
|
||||
self.riding = None;
|
||||
}
|
||||
// AbstractMinecart.activateMinecart: a powered activator rail ejects
|
||||
// the rider, and getDismountLocationForPassenger places it beside the
|
||||
// cart instead of leaving it at the seat.
|
||||
if alive && self.riding.is_none() {
|
||||
self.dismount_cart(i);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
|
||||
|
||||
@@ -20,6 +20,102 @@ impl Game<'_> {
|
||||
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
|
||||
self.music
|
||||
.tick(music.as_ref(), volume, loading, &mut self.audio);
|
||||
// Options.musicToast's listener: ToastManager.setMusicToastDisplayState.
|
||||
let state = self.settings.file.int("musicToast").clamp(0, 2) as u8;
|
||||
if state != self.music_toast_state {
|
||||
self.music_toast_state = state;
|
||||
self.set_music_toast_state(state);
|
||||
}
|
||||
// MusicManager's showNowPlayingToast and hideNowPlayingToast calls.
|
||||
let multiplier = f64::from(self.settings.notification_display_time);
|
||||
for show in std::mem::take(&mut self.music.toast_requests) {
|
||||
if let Some(toast) = &mut self.now_playing {
|
||||
if show {
|
||||
toast.show(multiplier);
|
||||
} else {
|
||||
toast.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Options.getFinalSoundSourceVolume(SoundSource.MUSIC)`.
|
||||
fn final_music_volume(&self) -> f32 {
|
||||
self.settings.master_volume * self.settings.volumes[1]
|
||||
}
|
||||
/// `ToastManager.setMusicToastDisplayState`: Pause and Pause and Toast make
|
||||
/// the toast (the second one shows it at once while music is audible),
|
||||
/// Never drops it.
|
||||
fn set_music_toast_state(&mut self, state: u8) {
|
||||
self.now_playing = match state {
|
||||
0 => None,
|
||||
_ => {
|
||||
let mut toast = horizon_client::toasts::NowPlayingToast::default();
|
||||
if state == 2 && self.final_music_volume() > 0.0 {
|
||||
toast.show(f64::from(self.settings.notification_display_time));
|
||||
}
|
||||
Some(toast)
|
||||
}
|
||||
};
|
||||
}
|
||||
/// `NowPlayingToast.getNowPlayingString` of the song the music manager
|
||||
/// plays: its file's short language key, slashes as dots.
|
||||
fn now_playing_song(&self) -> Option<String> {
|
||||
let id = self.music.current_instance()?;
|
||||
let location = self
|
||||
.audio
|
||||
.engine
|
||||
.instance(id)?
|
||||
.sound
|
||||
.as_ref()?
|
||||
.location
|
||||
.as_str();
|
||||
let (namespace, path) = location.split_once(':').unwrap_or(("minecraft", location));
|
||||
// Identifier.toShortLanguageKey.
|
||||
let key = if namespace == "minecraft" {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("{namespace}.{path}")
|
||||
}
|
||||
.replace('/', ".");
|
||||
Some(horizon_client::lang::get_or(&key, &key))
|
||||
}
|
||||
/// `ToastManager.update` (it plays no toast sound for this toast) and
|
||||
/// `extractRenderState` for the Now Playing toast, which slides in at the
|
||||
/// top left over every screen but the pause screen, and
|
||||
/// `PauseScreen.extractRenderState` with its `tick`, which draws the song
|
||||
/// name on the pause menu under the Pause and Pause and Toast settings
|
||||
/// while music is audible. `hidden` (F1) keeps the toast
|
||||
/// timing out without drawing it.
|
||||
pub(super) fn draw_music_toast(
|
||||
&mut self,
|
||||
batch: &mut horizon_client::hud::HudBatch,
|
||||
hidden: bool,
|
||||
) {
|
||||
let song = self.now_playing_song();
|
||||
let Some(toast) = self.now_playing.as_mut() else {
|
||||
return;
|
||||
};
|
||||
toast.update(song.is_some(), &mut self.music_notes);
|
||||
let Some(song) = song else {
|
||||
return;
|
||||
};
|
||||
if self.screen == super::Screen::Paused {
|
||||
if self.pause_menu && self.final_music_volume() > 0.0 {
|
||||
self.music_notes.tick();
|
||||
horizon_client::toasts::draw_now_playing(
|
||||
&self.gui.atlas,
|
||||
batch,
|
||||
0.0,
|
||||
&song,
|
||||
self.music_notes.color,
|
||||
);
|
||||
}
|
||||
} else if !hidden
|
||||
&& self.music_toast_state == 2
|
||||
&& let Some(toast) = &self.now_playing
|
||||
{
|
||||
toast.draw(&self.gui.atlas, batch, &song, &self.music_notes);
|
||||
}
|
||||
}
|
||||
/// `Minecraft.tick` -> `SoundManager.tick(pause)`, after the music, with
|
||||
/// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it
|
||||
|
||||
@@ -12,6 +12,9 @@ use horizon_client::{
|
||||
scoreboard as sb, text::Text,
|
||||
};
|
||||
|
||||
/// The world size of one text pixel in a name tag: `Matrix4f.scale(0.025F, -0.025F, 0.025F)`.
|
||||
pub(super) const NAME_TAG_UNIT: f32 = 0.025;
|
||||
|
||||
impl Game<'_> {
|
||||
/// A named name tag names the mob under the crosshair.
|
||||
pub(super) fn name_mob(&mut self, index: usize) -> bool {
|
||||
@@ -324,16 +327,43 @@ impl Game<'_> {
|
||||
if tag.discrete {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:name_tag");
|
||||
let light = vertex_light(tag.light);
|
||||
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 0, light, out));
|
||||
patches.extend(self.name_tag_quads(
|
||||
o,
|
||||
&tag.lines,
|
||||
FADED,
|
||||
true,
|
||||
0,
|
||||
light,
|
||||
NAME_TAG_UNIT,
|
||||
out,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
// LightCoordsUtil.lightCoordsWithEmission(lightCoords, 2).
|
||||
let lit = (tag.light >> 4).max(2) << 4 | (tag.light & 15).max(2);
|
||||
self.mark_entity(out.len(), "minecraft:name_tag");
|
||||
let light = vertex_light(lit);
|
||||
patches.extend(self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, light, out));
|
||||
patches.extend(self.name_tag_quads(
|
||||
o,
|
||||
&tag.lines,
|
||||
0xFF,
|
||||
false,
|
||||
0,
|
||||
light,
|
||||
NAME_TAG_UNIT,
|
||||
out,
|
||||
));
|
||||
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
|
||||
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through));
|
||||
patches.extend(self.name_tag_quads(
|
||||
o,
|
||||
&tag.lines,
|
||||
FADED,
|
||||
true,
|
||||
2,
|
||||
0,
|
||||
NAME_TAG_UNIT,
|
||||
see_through,
|
||||
));
|
||||
}
|
||||
self.map_patches.extend(patches);
|
||||
}
|
||||
@@ -342,7 +372,7 @@ impl Game<'_> {
|
||||
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
|
||||
/// Returns the glyph pixels the atlas still needs.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn name_tag_quads(
|
||||
pub(super) fn name_tag_quads(
|
||||
&self,
|
||||
o: glam::DVec3,
|
||||
lines: &[(Text, f64)],
|
||||
@@ -350,6 +380,7 @@ impl Game<'_> {
|
||||
background: bool,
|
||||
text_layer: u32,
|
||||
light: u32,
|
||||
unit: f32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Vec<horizon_client::font::Patch> {
|
||||
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
|
||||
@@ -369,7 +400,7 @@ impl Game<'_> {
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
for (text, lift) in lines {
|
||||
let o = (o + glam::DVec3::new(0.0, *lift, 0.0)).as_vec3();
|
||||
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
|
||||
let (r, u) = (right.as_vec3() * unit, up.as_vec3() * unit);
|
||||
// Text space: x right, y down (scale 0.025, -0.025).
|
||||
let at = |x: f32, y: f32| o + r * x - u * y;
|
||||
let y0 = if text.plain() == "deadmau5" {
|
||||
|
||||
@@ -807,7 +807,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
/// Server-level helpers for native mods.
|
||||
#[allow(dead_code)]
|
||||
impl GameLevel<'_> {
|
||||
@@ -855,11 +854,20 @@ impl GameLevel<'_> {
|
||||
LevelDimension::End => "minecraft:the_end",
|
||||
};
|
||||
self.native_particles
|
||||
.push((dimension, options.clone(), state, at, spread, speed, count));
|
||||
.push(super::lan_particles::QueuedParticle {
|
||||
dimension,
|
||||
options: options.clone(),
|
||||
state,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
count,
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
@@ -1743,7 +1751,7 @@ mod tests {
|
||||
.split("/// The hook points. Each does nothing in Horizon.")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("/// Helpers for native mods")
|
||||
.split("/// Server-level helpers for native mods.")
|
||||
.next()
|
||||
.unwrap();
|
||||
let level_section = source
|
||||
|
||||
@@ -373,6 +373,9 @@ impl Game<'_> {
|
||||
max_section,
|
||||
);
|
||||
if let Some(found) = found {
|
||||
// NetherPortalBlock.getExitPortal: the ticket goes on the chunk
|
||||
// of the portal found.
|
||||
self.place_portal_ticket((found.x >> 4, found.z >> 4));
|
||||
let state = self.world.block(found.x, found.y, found.z);
|
||||
let x_axis = self.res.behaviours().portal_axis(state).unwrap_or(true);
|
||||
return Some(largest_rectangle_around(
|
||||
@@ -390,8 +393,28 @@ impl Game<'_> {
|
||||
b.create_portal(level, ticks, approximate, x_axis, &border, logical)
|
||||
});
|
||||
let _ = self.process_level_events();
|
||||
if let Some(exit) = created {
|
||||
// A portal built for the entity: the ticket goes on the chunk it
|
||||
// arrives in (`TeleportTransition.PLACE_PORTAL_TICKET`).
|
||||
let (at, _) = self.arrival(plan, exit);
|
||||
self.place_portal_ticket(Self::chunk_of(at));
|
||||
}
|
||||
created
|
||||
}
|
||||
/// `ChunkPos.containing`: the chunk a position is in.
|
||||
pub(super) fn chunk_of(at: Vec3) -> (i32, i32) {
|
||||
((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4)
|
||||
}
|
||||
/// `Entity.placePortalTicket`: a `TicketType.PORTAL` ticket of radius 3
|
||||
/// on `chunk` in the level in place, which keeps the area running for 300
|
||||
/// ticks with no player in it (the entity that just came through ticks
|
||||
/// there, and goes back through the portal when its cooldown ends).
|
||||
/// Only a server holds levels with tickets of their own.
|
||||
pub(super) fn place_portal_ticket(&mut self, chunk: (i32, i32)) {
|
||||
if !self.options.execution_role.is_client() {
|
||||
self.tickets.add_portal(chunk);
|
||||
}
|
||||
}
|
||||
/// `NetherPortalBlock.createDimensionTransition`: the arrival position
|
||||
/// and the yaw turned by.
|
||||
pub(super) fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
|
||||
@@ -1038,6 +1061,8 @@ impl Game<'_> {
|
||||
let (at, motion, [yaw, pitch]) = if plan.end {
|
||||
// Relative.DELTA: the motion is kept.
|
||||
let at = self.end_portal_exit(&plan);
|
||||
// EndPortalBlock.getPortalDestination: PLACE_PORTAL_TICKET.
|
||||
self.place_portal_ticket(Self::chunk_of(at));
|
||||
let into_end = plan.dim == Dim::End;
|
||||
let turn =
|
||||
end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle);
|
||||
|
||||
@@ -106,13 +106,55 @@ impl Game<'_> {
|
||||
self.keys.clear();
|
||||
self.screen = Screen::Options;
|
||||
self.options_from_title = from_title;
|
||||
self.options_to_game = false;
|
||||
let ui = &mut self.ui.options;
|
||||
ui.open_at(!from_title && self.title.is_none(), page);
|
||||
let root = self.options.resources.clone();
|
||||
ui.languages = lang::available(&[&root]);
|
||||
self.refresh_pack_lists();
|
||||
}
|
||||
/// While the resource pack screen is open, `resourcepacks/` is looked at once a second
|
||||
/// and the list found again when its packs changed (`PackSelectionScreen.tick`'s
|
||||
/// watcher).
|
||||
fn watch_pack_folder(&mut self) {
|
||||
if !self.ui.options.resource_packs_open() {
|
||||
self.pack_folder = None;
|
||||
return;
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
if self
|
||||
.pack_folder
|
||||
.as_ref()
|
||||
.is_some_and(|(at, _)| now.duration_since(*at).as_secs_f32() < 1.0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let signature: Vec<(String, u64, Option<std::time::SystemTime>)> =
|
||||
std::fs::read_dir(super::packs::packs_dir())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let meta = e.metadata().ok();
|
||||
(
|
||||
e.file_name().to_string_lossy().into_owned(),
|
||||
meta.as_ref().map_or(0, |m| m.len()),
|
||||
meta.and_then(|m| m.modified().ok()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let changed = self
|
||||
.pack_folder
|
||||
.as_ref()
|
||||
.is_some_and(|(_, seen)| *seen != signature);
|
||||
self.pack_folder = Some((now, signature));
|
||||
if changed {
|
||||
let infos = super::packs::pack_infos(&self.vanilla_root());
|
||||
self.ui.options.update_pack_infos(infos);
|
||||
}
|
||||
}
|
||||
pub(super) fn draw_options(&mut self, batch: &mut HudBatch) {
|
||||
self.watch_pack_folder();
|
||||
let mut ui = std::mem::take(&mut self.ui.options);
|
||||
ui.draw(&self.settings, batch, &self.gui.atlas, self.cursor);
|
||||
self.ui.options = ui;
|
||||
@@ -234,13 +276,17 @@ impl Game<'_> {
|
||||
Event::Close if self.ui.options.resource_error.is_some() => {}
|
||||
Event::Close => {
|
||||
self.settings.save(&Self::options_path());
|
||||
let to_game = std::mem::take(&mut self.options_to_game);
|
||||
self.screen = if std::mem::take(&mut self.options_to_world_options) {
|
||||
Screen::WorldOptions
|
||||
} else if self.options_from_title {
|
||||
} else if self.options_from_title || to_game {
|
||||
Screen::Playing
|
||||
} else {
|
||||
Screen::Paused
|
||||
};
|
||||
if to_game {
|
||||
self.capture(true);
|
||||
}
|
||||
self.dragging = false;
|
||||
}
|
||||
Event::Language(code) => {
|
||||
@@ -327,9 +373,14 @@ impl Game<'_> {
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.gamma = s.gamma;
|
||||
r.fullbright = s.extras.fullbright;
|
||||
r.improved_transparency = s.improved_transparency;
|
||||
r.set_present_mode(s.vsync, s.horizon.present_mode);
|
||||
r.animated_textures = s.horizon.animated_textures;
|
||||
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
|
||||
r.set_texture_filtering(
|
||||
s.file.int("textureFiltering"),
|
||||
s.file.int("maxAnisotropyBit"),
|
||||
);
|
||||
r.set_world_post(
|
||||
s.horizon.resolution_scale as f32 / 100.0,
|
||||
s.horizon.fxaa,
|
||||
|
||||
@@ -8,6 +8,7 @@ use horizon_client::{
|
||||
Result,
|
||||
gamepad::{self, Button, Edges, GlyphStyle, HealthWatch, PadState, Rumble},
|
||||
keybinds::{KeyBinds, key_code, mouse_button},
|
||||
keymapping::SNEAK,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Instant;
|
||||
@@ -213,12 +214,16 @@ impl Game<'_> {
|
||||
"key.sneak"
|
||||
if self.settings.controller.toggle_sneak && !self.settings.toggle_crouch =>
|
||||
{
|
||||
// A stick click flips sneaking instead of holding it.
|
||||
if down {
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
self.mapped_key(KeyCode::ShiftLeft, !sneaking, false)?;
|
||||
// A stick click flips sneaking instead of holding it, as
|
||||
// vanilla's toggle crouch does: a screen opening releases it
|
||||
// and closing restores it (ToggleKeyMapping).
|
||||
if !down {
|
||||
return Ok(());
|
||||
}
|
||||
Ok(())
|
||||
let screen_open = self.screen != Screen::Playing || self.title.is_some();
|
||||
self.toggles.pad_toggle(SNEAK, screen_open);
|
||||
self.sync_toggle(SNEAK);
|
||||
self.key_pressed(KeyCode::ShiftLeft)
|
||||
}
|
||||
_ => {
|
||||
let default = KeyBinds::default_of(action);
|
||||
|
||||
@@ -57,6 +57,16 @@ fn is_dualsense(pad: &gilrs::Gamepad) -> bool {
|
||||
(Some(vendor), Some(product)) if horizon_dualsense::is_dualsense(vendor, product))
|
||||
}
|
||||
|
||||
/// SDL rows gilrs' bundled DB is missing (or only ships for Windows). Newer
|
||||
/// than the crate's copy; `GilrsBuilder::add_mappings` prefers these. The
|
||||
/// Ultimate 2's Linux/BT rows put the right stick on a2/a3 — without them
|
||||
/// look never moves (#4343).
|
||||
const EXTRA_SDL_MAPPINGS: &str = concat!(
|
||||
"03000000c82d00001260000011010000,8BitDo Ultimate 2,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b17,paddle2:b16,paddle3:b2,paddle4:b5,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux,\n",
|
||||
"05000000c82d00001260000001000000,8BitDo Ultimate 2,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b17,paddle2:b16,paddle3:b2,paddle4:b5,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux,\n",
|
||||
"03000000c82d00000b31000014010000,8BitDo Ultimate 2,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux,\n",
|
||||
);
|
||||
|
||||
impl Pads {
|
||||
/// Open gilrs on first use. A platform without it (or a Linux without
|
||||
/// udev) leaves controllers off; the game runs as before.
|
||||
@@ -65,7 +75,10 @@ impl Pads {
|
||||
return;
|
||||
}
|
||||
self.devices.started = true;
|
||||
self.devices.gilrs = match gilrs::Gilrs::new() {
|
||||
self.devices.gilrs = match gilrs::GilrsBuilder::new()
|
||||
.add_mappings(EXTRA_SDL_MAPPINGS)
|
||||
.build()
|
||||
{
|
||||
Ok(g) => {
|
||||
for (_, pad) in g.gamepads() {
|
||||
log::info!("Controller connected: {}", pad.name());
|
||||
@@ -132,8 +145,17 @@ impl Pads {
|
||||
}
|
||||
let axis = |a| pad.value(a);
|
||||
use gilrs::Axis as A;
|
||||
state.left = [axis(A::LeftStickX), axis(A::LeftStickY)];
|
||||
state.right = [axis(A::RightStickX), axis(A::RightStickY)];
|
||||
let sdl_mapped = pad.mapping_source() == gilrs::MappingSource::SdlMappings;
|
||||
let (left, right) = gamepad::stick_axes(
|
||||
[axis(A::LeftStickX), axis(A::LeftStickY)],
|
||||
[axis(A::RightStickX), axis(A::RightStickY)],
|
||||
axis(A::LeftZ),
|
||||
pad.vendor_id(),
|
||||
pad.product_id(),
|
||||
sdl_mapped,
|
||||
);
|
||||
state.left = left;
|
||||
state.right = right;
|
||||
// Triggers arrive as analog buttons on most mappings, as plain
|
||||
// buttons on some.
|
||||
let trigger = |b: gilrs::Button| {
|
||||
|
||||
@@ -146,6 +146,33 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Armor stands riding a mob or the local player (a server's name tag
|
||||
// hologram): seated like the displays above, so the name follows its
|
||||
// vehicle instead of staying where the stand spawned. The stand's own
|
||||
// vehicle attachment is the default 0, so its feet sit on the seat.
|
||||
for i in 0..self.armor_stands.len() {
|
||||
let Some(vehicle) = self.armor_stands[i].vehicle else {
|
||||
continue;
|
||||
};
|
||||
let at = if Some(vehicle) == me {
|
||||
let Some(at) = own_seat else { continue };
|
||||
at
|
||||
} else {
|
||||
let Some(&j) = index.get(&vehicle) else {
|
||||
continue;
|
||||
};
|
||||
let vehicle = &self.mobs[j];
|
||||
riders::seat(
|
||||
riders::passenger_attachment(vehicle),
|
||||
0.0,
|
||||
vehicle.position,
|
||||
vehicle.yaw,
|
||||
)
|
||||
};
|
||||
let stand = &mut self.armor_stands[i];
|
||||
stand.position = at;
|
||||
stand.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getPassengerRidingPosition` of a vehicle: where the feet of a
|
||||
|
||||
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user