mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-12 13:03:44 -10:00
Compare commits
340
Commits
mem/block-size
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c1a43138c | ||
|
|
1a62ef7eb8 | ||
|
|
fd448c5dc3 | ||
|
|
e0d0e34ba3 | ||
|
|
4e459bf8e7 | ||
|
|
15f090e126 | ||
|
|
f21b83c8b2 | ||
|
|
b929bea501 | ||
|
|
ed2d455bf1 | ||
|
|
6dd6357962 | ||
|
|
6416121902 | ||
|
|
eb8b298f9e | ||
|
|
c5127a8777 | ||
|
|
911bbf054b | ||
|
|
f646074830 | ||
|
|
5e3b0f03b6 | ||
|
|
bb64b215fe | ||
|
|
779ea29839 | ||
|
|
74fd49ced0 | ||
|
|
8cf76b5ced | ||
|
|
53759e036a | ||
|
|
950b9fed63 | ||
|
|
01f8706d10 | ||
|
|
ba4e4cf377 | ||
|
|
c442809fa7 | ||
|
|
af682746e0 | ||
|
|
f34daa1a75 | ||
|
|
130d3ce7b6 | ||
|
|
4acdda54f2 | ||
|
|
a1c4de50b1 | ||
|
|
3b48fdf7c4 | ||
|
|
02b94e5b12 | ||
|
|
51a4fe79e6 | ||
|
|
cd0f850c12 | ||
|
|
97fc687ceb | ||
|
|
f7fce57158 | ||
|
|
fb50a7ad88 | ||
|
|
db82ec5ba0 | ||
|
|
0f93711e13 | ||
|
|
2524bc9ea2 | ||
|
|
fcfa91e598 | ||
|
|
deb3b4fec3 | ||
|
|
3fd1593ca6 | ||
|
|
95d99935ea | ||
|
|
31fc9ce21d | ||
|
|
72f92a5ef7 | ||
|
|
914d5e664d | ||
|
|
23b4c9c411 | ||
|
|
95e346c9ad | ||
|
|
6f478a6cad | ||
|
|
737f81c32d | ||
|
|
408c08e029 | ||
|
|
44c7007125 | ||
|
|
551cb3a741 | ||
|
|
683603fe21 | ||
|
|
5b29177859 | ||
|
|
3d6224deb4 | ||
|
|
d1613f705f | ||
|
|
e78022f152 | ||
|
|
5c4cdb41f6 | ||
|
|
3cac36d0f1 | ||
|
|
50d2c4f3e8 | ||
|
|
5e89bcee59 | ||
|
|
97aa3cf252 | ||
|
|
0ae3420fd5 | ||
|
|
20b1949dbd | ||
|
|
5db98af340 | ||
|
|
00b3fa3db7 | ||
|
|
9b2f425f42 | ||
|
|
a0a2a53279 | ||
|
|
13d6ba09ad | ||
|
|
1352566ab3 | ||
|
|
ca80643fda | ||
|
|
e9faef471e | ||
|
|
7beafea5e1 | ||
|
|
9e840cecfd | ||
|
|
78f6ac5536 | ||
|
|
5ca5e36230 | ||
|
|
3e5a89b088 | ||
|
|
0609305694 | ||
|
|
b723023664 | ||
|
|
a5f6b3ba0e | ||
|
|
66129b4902 | ||
|
|
ac2603ed41 | ||
|
|
58d42c41ed | ||
|
|
dffce9c48d | ||
|
|
42e897cc73 | ||
|
|
ebd4205ea6 | ||
|
|
14859c67b7 | ||
|
|
542c7c239e | ||
|
|
93d98006a8 | ||
|
|
12b96b27c3 | ||
|
|
5b912979d4 | ||
|
|
e518cb868d | ||
|
|
3c1027d1e1 | ||
|
|
ae8e375feb | ||
|
|
7e8805bb61 | ||
|
|
fb089da76c | ||
|
|
d162b9f5e5 | ||
|
|
748c6ba3d5 | ||
|
|
3665978732 | ||
|
|
5364cb4ebc | ||
|
|
855e215c43 | ||
|
|
7c8f5e38a6 | ||
|
|
108bf1dd93 | ||
|
|
3fc31df485 | ||
|
|
633d28079b | ||
|
|
e5efe9dc8d | ||
|
|
2b8ce785e1 | ||
|
|
d52edf0341 | ||
|
|
852dcc5416 | ||
|
|
62d8085479 | ||
|
|
72627408bc | ||
|
|
91c0bf1f42 | ||
|
|
c26187a6f0 | ||
|
|
5a135748c8 | ||
|
|
5208a5385b | ||
|
|
d3c30ae2b0 | ||
|
|
eb44631ea7 | ||
|
|
53209c8800 | ||
|
|
5700ead75f | ||
|
|
e3b03d4adc | ||
|
|
387fc29cb8 | ||
|
|
16448c5ff1 | ||
|
|
38f23d16e4 | ||
|
|
5b4d583209 | ||
|
|
ebf8069c57 | ||
|
|
1751960d36 | ||
|
|
35e7459102 | ||
|
|
b942e89cd6 | ||
|
|
f117ec604a | ||
|
|
34dcda27f0 | ||
|
|
ee8b7b891a | ||
|
|
9a314a0b5a | ||
|
|
68ac8880d3 | ||
|
|
717b8d192f | ||
|
|
7cd92e310d | ||
|
|
541ef4e5fa | ||
|
|
baa43ddf05 | ||
|
|
337b8a751c | ||
|
|
f31921a9a3 | ||
|
|
1aa472967f | ||
|
|
cc1fea2714 | ||
|
|
40ca777682 | ||
|
|
036dc77592 | ||
|
|
2a3b142012 | ||
|
|
6d3692c673 | ||
|
|
383b68b576 | ||
|
|
69fdcefa2a | ||
|
|
59493f4837 | ||
|
|
0a80c3d42f | ||
|
|
805f69c8a9 | ||
|
|
d78f0c49c9 | ||
|
|
9303b88c79 | ||
|
|
ecc1570388 | ||
|
|
050e90b150 | ||
|
|
87be63d40a | ||
|
|
278d0355e9 | ||
|
|
83e0929684 | ||
|
|
e3e866eb41 | ||
|
|
a4a529a565 | ||
|
|
336b647f63 | ||
|
|
f181472b63 | ||
|
|
875b98b71f | ||
|
|
5e177afe1d | ||
|
|
59972a243d | ||
|
|
92eb621473 | ||
|
|
061ba23407 | ||
|
|
80eca9b7e2 | ||
|
|
af9e445fbd | ||
|
|
745274bee2 | ||
|
|
f85db126cc | ||
|
|
8286150240 | ||
|
|
62eb25aa1d | ||
|
|
c497ba5279 | ||
|
|
22bfbbd417 | ||
|
|
c950a3dafe | ||
|
|
f92b723265 | ||
|
|
784eb9f185 | ||
|
|
aa71621cae | ||
|
|
e829142877 | ||
|
|
4ae80fa5ea | ||
|
|
4484d84eed | ||
|
|
0cc70624e2 | ||
|
|
f48bf07992 | ||
|
|
19fa5d003a | ||
|
|
bad917dea7 | ||
|
|
cd1c3ad784 | ||
|
|
9d9edcc5c7 | ||
|
|
2106fb21d8 | ||
|
|
4752a44392 | ||
|
|
869df6be8e | ||
|
|
10ea380a42 | ||
|
|
da19583b0e | ||
|
|
a4bf39fccf | ||
|
|
a856c936d6 | ||
|
|
79b225758e | ||
|
|
f26c009854 | ||
|
|
783c5d4bd5 | ||
|
|
8c2241221b | ||
|
|
fb003e9b48 | ||
|
|
e243b417ec | ||
|
|
0e06fcffe8 | ||
|
|
c187167717 | ||
|
|
09449b8b5a | ||
|
|
3a44c61b5c | ||
|
|
adf711981d | ||
|
|
b140490175 | ||
|
|
7244070cdf | ||
|
|
05ed4ff36f | ||
|
|
765595fc00 | ||
|
|
071685a3cf | ||
|
|
1aef29fada | ||
|
|
80ef919e40 | ||
|
|
ad8e395f3c | ||
|
|
f071ffe84b | ||
|
|
525c1c9834 | ||
|
|
627864a666 | ||
|
|
efaac3cdc0 | ||
|
|
6c090eb39d | ||
|
|
4e44044828 | ||
|
|
59319a8e2d | ||
|
|
3086bc8910 | ||
|
|
632b455a88 | ||
|
|
4a33f95d69 | ||
|
|
4347efb700 | ||
|
|
73e29da806 | ||
|
|
eb0605533b | ||
|
|
1db662da23 | ||
|
|
ece1d80195 | ||
|
|
858de11740 | ||
|
|
3528b8ce41 | ||
|
|
2525ab8ff8 | ||
|
|
8a205c4d53 | ||
|
|
6980c84dfc | ||
|
|
6d72da27f6 | ||
|
|
18e01a3ba2 | ||
|
|
c994a813ec | ||
|
|
ee18d85a3d | ||
|
|
1c7ff0f82d | ||
|
|
8be0fd72d2 | ||
|
|
6e6c114701 | ||
|
|
66493ad0a4 | ||
|
|
c3f130ec76 | ||
|
|
03ed9279f2 | ||
|
|
ceb22dff50 | ||
|
|
d025af8ef1 | ||
|
|
682d70268a | ||
|
|
fc67e09538 | ||
|
|
9ccd03e15c | ||
|
|
a45820d34e | ||
|
|
83583196d4 | ||
|
|
b11f2ea604 | ||
|
|
c52976236b | ||
|
|
8ec11feda8 | ||
|
|
3e3ed99fde | ||
|
|
56b3fbab8c | ||
|
|
dfcfa1a0db | ||
|
|
1759cc1565 | ||
|
|
b7414749eb | ||
|
|
ffb455858e | ||
|
|
ca1c65d957 | ||
|
|
d580bddaec | ||
|
|
2962f1172d | ||
|
|
ac68d5ae04 | ||
|
|
76a8f27085 | ||
|
|
0ba2a84306 | ||
|
|
40b2f16229 | ||
|
|
023a57e557 | ||
|
|
a15fb9d167 | ||
|
|
b1499e6662 | ||
|
|
cf0566a27d | ||
|
|
bdbf33939c | ||
|
|
d0314e8f25 | ||
|
|
03c750245c | ||
|
|
c9544070f8 | ||
|
|
361923c262 | ||
|
|
adbf42ffb9 | ||
|
|
fadace66a0 | ||
|
|
7c384cd6ae | ||
|
|
e61bf50d60 | ||
|
|
d1190a4dc7 | ||
|
|
bcb0d01d9e | ||
|
|
6ece267eb9 | ||
|
|
eb06abefe2 | ||
|
|
5fcd41ad3c | ||
|
|
c1b56cea13 | ||
|
|
fac0918973 | ||
|
|
cfa85f016f | ||
|
|
615735e12c | ||
|
|
c46fd6f944 | ||
|
|
18bb5b3b1d | ||
|
|
dc3fe491e3 | ||
|
|
d1619f89b3 | ||
|
|
4068a09887 | ||
|
|
09cfb0197c | ||
|
|
e8578c2e47 | ||
|
|
8c9c801232 | ||
|
|
1fd78941bd | ||
|
|
28584b59bf | ||
|
|
2f8540be63 | ||
|
|
91e62194e8 | ||
|
|
9094309407 | ||
|
|
4888b73428 | ||
|
|
d6fb844de4 | ||
|
|
81f336b814 | ||
|
|
d42af058e8 | ||
|
|
b4b9d03ed5 | ||
|
|
467d045271 | ||
|
|
45eb484b85 | ||
|
|
0dace8ff46 | ||
|
|
5054ffc2ab | ||
|
|
96a7044c15 | ||
|
|
182994edc8 | ||
|
|
0a48f1eade | ||
|
|
9f51db9b40 | ||
|
|
301fc5fc05 | ||
|
|
74db568335 | ||
|
|
f64da572af | ||
|
|
fe0a4d80bf | ||
|
|
adc454cfc9 | ||
|
|
2cdecbf069 | ||
|
|
72dff2d5b7 | ||
|
|
194a86eff3 | ||
|
|
3089267e7c | ||
|
|
895726d381 | ||
|
|
1f96d244f9 | ||
|
|
27979c816b | ||
|
|
0a11f464a3 | ||
|
|
b4733ffec0 | ||
|
|
1b8e661146 | ||
|
|
95b6b28fcb | ||
|
|
2f07a64084 | ||
|
|
bb6e18b94d | ||
|
|
b28069d08e | ||
|
|
2f4a142e8b | ||
|
|
83df38c895 | ||
|
|
d70f2fdb20 | ||
|
|
d8a86b2454 | ||
|
|
d4cfdeab5c |
No files matched your search
@@ -214,7 +214,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ The GLSL frontend also resets the common type for each comma-separated declarato
|
||||
variable's array specifier does not change later variables.
|
||||
It also reads a struct member outside a constant expression through a pointer to the member,
|
||||
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
|
||||
The shared interpolation defaults also compile with `glsl-in` alone, without needing the
|
||||
SPIR-V or WGSL frontend to enable them.
|
||||
|
||||
## DirectX Shader Compiler
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ impl Config {
|
||||
Ok(file.anticheat)
|
||||
}
|
||||
|
||||
/// Thresholds can't be negative.
|
||||
/// Thresholds can't be negative, and neither can the timer's milliseconds.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
for &c in Check::ALL {
|
||||
let a = self.actions(c);
|
||||
@@ -220,6 +220,18 @@ impl Config {
|
||||
));
|
||||
}
|
||||
}
|
||||
// A negative tolerance flags a client on time every tick, and so does a cap below minus the
|
||||
// tolerance (the balance rests at the cap); NaN never flags, or never caps the balance. No
|
||||
// negative number of milliseconds means anything, so all are refused.
|
||||
if [self.timer.tolerance_ms, self.timer.max_lag_ms]
|
||||
.iter()
|
||||
.any(|v| !v.is_finite() || *v < 0.0)
|
||||
{
|
||||
return Err(
|
||||
"anticheat.timer: tolerance_ms and max_lag_ms are numbers of milliseconds from 0 up"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let tolerance = self.prediction.tolerance;
|
||||
if !tolerance.is_finite() || tolerance <= 0.0 {
|
||||
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
|
||||
@@ -361,6 +373,26 @@ mod tests {
|
||||
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
|
||||
}
|
||||
|
||||
/// A negative `tolerance_ms` flags a client on time every tick, and so does a `max_lag_ms` below
|
||||
/// `-tolerance_ms` (the balance rests at the cap); a NaN turns the check (or its cap) off without a
|
||||
/// word. Every negative or non-finite value is refused, as a negative threshold is.
|
||||
#[test]
|
||||
fn refuses_timer_settings_that_are_not_milliseconds_from_0_up() {
|
||||
for bad in [
|
||||
"tolerance_ms = -1",
|
||||
"tolerance_ms = nan",
|
||||
"tolerance_ms = inf",
|
||||
"max_lag_ms = -1",
|
||||
"max_lag_ms = nan",
|
||||
] {
|
||||
let text = format!("[anticheat.timer]\n{bad}\n");
|
||||
let e = Config::from_server_toml(&text).expect_err(bad);
|
||||
assert!(e.starts_with("anticheat.timer:"), "{bad}: {e}");
|
||||
}
|
||||
let edge = "[anticheat.timer]\ntolerance_ms = 0\nmax_lag_ms = 0\n";
|
||||
assert!(Config::from_server_toml(edge).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_rise_decay_and_act_in_order() {
|
||||
let mut c = on();
|
||||
|
||||
@@ -45,6 +45,11 @@ mod tests {
|
||||
|
||||
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
|
||||
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
|
||||
run_at(b, 20.0, server_ms, per_tick, seconds)
|
||||
}
|
||||
|
||||
/// [`run`] at the tick rate `rate` (`/tick rate`), which the client's ticks follow.
|
||||
fn run_at(b: &mut Balance, rate: f32, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
|
||||
let c = TimerConfig::default();
|
||||
let mut owed = 0.0;
|
||||
let mut flags = 0;
|
||||
@@ -53,12 +58,31 @@ mod tests {
|
||||
owed += per_tick;
|
||||
while owed >= 1.0 {
|
||||
owed -= 1.0;
|
||||
flags += usize::from(b.tick_end(20.0, &c).is_some());
|
||||
flags += usize::from(b.tick_end(rate, &c).is_some());
|
||||
}
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
/// At another tick rate a vanilla client ticks at the rate the server sent it, and each of its
|
||||
/// ticks is worth `1000 / rate` ms: never flagged on time, flagged again and again 1.5 times as fast.
|
||||
#[test]
|
||||
fn the_balance_follows_the_tick_rate() {
|
||||
for rate in [10.0_f32, 60.0] {
|
||||
let tick_ms = 1000.0 / f64::from(rate);
|
||||
assert_eq!(
|
||||
run_at(&mut Balance::default(), rate, tick_ms, 1.0, 600.0),
|
||||
0,
|
||||
"on time at {rate}"
|
||||
);
|
||||
// Half a tick ahead every server tick: 75 flags in a minute at 10 a second, 94 at 60.
|
||||
let flags = run_at(&mut Balance::default(), rate, tick_ms, 1.5, 60.0);
|
||||
assert!(flags >= 50, "1.5 times as fast at {rate}: {flags} flags");
|
||||
}
|
||||
// The rate counts: at 10 a second, a client sending 20 ticks a second is twice as fast.
|
||||
assert!(run_at(&mut Balance::default(), 10.0, 100.0, 2.0, 60.0) >= 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vanilla_client_is_never_flagged() {
|
||||
let mut b = Balance::default();
|
||||
|
||||
@@ -1442,6 +1442,8 @@ impl Audio {
|
||||
} else {
|
||||
Err("no output on a dedicated server".into())
|
||||
};
|
||||
// Without `audio` a stream can't exist (`Infallible`), so the `Ok` arm never runs.
|
||||
#[cfg_attr(not(feature = "audio"), allow(unreachable_code))]
|
||||
let (stream, live) = match opened {
|
||||
Ok(v) => (Some(v), true),
|
||||
Err(e) => {
|
||||
|
||||
@@ -465,6 +465,9 @@ pub struct Jukebox {
|
||||
/// reference exists. Keep this separate from legacy playback adoption.
|
||||
pub song_clock_present: bool,
|
||||
pub playing: bool,
|
||||
/// A clock a save brought back, not playing yet: `setSongWithoutPlaying`
|
||||
/// keeps the song and its clock without the sound's level event 1010.
|
||||
pub loaded_clock: bool,
|
||||
}
|
||||
impl Jukebox {
|
||||
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
|
||||
|
||||
@@ -210,6 +210,7 @@ mod hdr_icc;
|
||||
mod hdr_start;
|
||||
mod hdr_stats;
|
||||
pub(crate) mod hud_coverage;
|
||||
mod in_flight;
|
||||
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
|
||||
mod hold;
|
||||
pub use hold::WorldHold;
|
||||
@@ -12351,133 +12352,6 @@ pub(crate) fn copy_frame(
|
||||
);
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
/// The frame's readbacks to start right after its submission (on the submit thread when
|
||||
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
|
||||
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
|
||||
fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
|
||||
let cull = self.cull.as_mut().and_then(|c| c.submitted());
|
||||
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
|
||||
match (cull, gpu) {
|
||||
(Some(c), Some(g)) => Some(Box::new(move || {
|
||||
c();
|
||||
g();
|
||||
})),
|
||||
(Some(c), None) => Some(Box::new(c)),
|
||||
(None, Some(g)) => Some(Box::new(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
|
||||
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
|
||||
fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
|
||||
if frame > self.stats_frame {
|
||||
self.stats_frame = frame;
|
||||
(
|
||||
self.drawn_sections,
|
||||
self.section_draws,
|
||||
self.section_draw_calls,
|
||||
) = (sections, draws, calls);
|
||||
}
|
||||
}
|
||||
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
|
||||
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
|
||||
pub fn stats_lag(&self) -> u64 {
|
||||
self.frame_serial - self.stats_frame
|
||||
}
|
||||
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
|
||||
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
|
||||
fn land_frame(&mut self) {
|
||||
let Some(offscreen) = self.deferred.take() else {
|
||||
return;
|
||||
};
|
||||
let submission = self.queue.landed();
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.submitted(&self.queue);
|
||||
timer.collect(&self.device, &mut self.gpu_times);
|
||||
}
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some(timer) = &mut self.own_timing {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if offscreen && let Some(submission) = submission {
|
||||
self.in_flight.push_back(submission);
|
||||
if !self.paced_elsewhere {
|
||||
self.wait_frames();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
pub fn wait_in_flight(&mut self) {
|
||||
self.land_frame();
|
||||
self.wait_frames();
|
||||
}
|
||||
/// The frame on the submit thread submitted: a surface reconfigure waits for the
|
||||
/// device and breaks if a frame is submitted while it waits, and the compositor
|
||||
/// copies views after them. (The end of a frame already lands the frames of the
|
||||
/// turn before; this is the turn's own.)
|
||||
pub fn wait_submissions(&mut self) {
|
||||
self.queue.wait();
|
||||
}
|
||||
fn wait_frames(&mut self) {
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: index,
|
||||
timeout: Some(std::time::Duration::from_secs(5)),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
|
||||
// Other 16-bit frames keep their clipped 8-bit copy.
|
||||
let copy = self
|
||||
.hdr
|
||||
.as_mut()
|
||||
.and_then(|hdr| {
|
||||
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
|
||||
})
|
||||
.or_else(|| {
|
||||
(texture.format() != format && texture.format() == self.output.frame.format)
|
||||
.then(|| {
|
||||
self.readback
|
||||
.unorm_copy(&self.device, encoder, texture, format)
|
||||
})
|
||||
});
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
let error = format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
);
|
||||
if request.background {
|
||||
self.saved_screenshots.push(screenshot_save::Saved {
|
||||
path: request.path,
|
||||
result: Err(error),
|
||||
hdr: None,
|
||||
});
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(error));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
@@ -12583,56 +12457,8 @@ fn block_entities_within_reach<'a>(
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
planes: [Vec4; 6],
|
||||
}
|
||||
impl Frustum {
|
||||
/// Whether the camera-relative box `min..max` is at least partly inside.
|
||||
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
!self.planes.iter().any(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether a camera-relative sphere is at least partly inside. Frustum
|
||||
/// planes are normalized, so each test needs only one dot product and a
|
||||
/// radius comparison.
|
||||
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
|
||||
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
|
||||
return true;
|
||||
}
|
||||
!self
|
||||
.planes
|
||||
.iter()
|
||||
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
|
||||
}
|
||||
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
|
||||
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
|
||||
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
|
||||
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
|
||||
return true;
|
||||
}
|
||||
!self.planes[4..].iter().any(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether the camera-relative box `min..max` is entirely inside. Then
|
||||
/// `intersects` accepts every box within it: each plane's distance only
|
||||
/// grows from this box's nearest corner to the inner box's farthest, and
|
||||
/// rounding keeps that order.
|
||||
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
self.planes.iter().all(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, min, max);
|
||||
p.truncate().dot(v) + p.w >= 0.0
|
||||
})
|
||||
}
|
||||
}
|
||||
mod view_frustum;
|
||||
pub use view_frustum::Frustum;
|
||||
/// Vanilla's `Projection`: reversed depth (near maps to 1, far to 0, tests
|
||||
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
|
||||
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Frames in flight: the readbacks a submission starts, landing frames the submit thread
|
||||
//! submitted, keeping offscreen frames at most two ahead of the GPU, and a screenshot's copy.
|
||||
|
||||
use super::{Renderer, ScreenshotRequest, copy_frame, screenshot_save, submit};
|
||||
|
||||
impl Renderer {
|
||||
/// The frame's readbacks to start right after its submission (on the submit thread when
|
||||
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
|
||||
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
|
||||
pub(super) fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
|
||||
let cull = self.cull.as_mut().and_then(|c| c.submitted());
|
||||
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
|
||||
match (cull, gpu) {
|
||||
(Some(c), Some(g)) => Some(Box::new(move || {
|
||||
c();
|
||||
g();
|
||||
})),
|
||||
(Some(c), None) => Some(Box::new(c)),
|
||||
(None, Some(g)) => Some(Box::new(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
|
||||
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
|
||||
pub(super) fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
|
||||
if frame > self.stats_frame {
|
||||
self.stats_frame = frame;
|
||||
(
|
||||
self.drawn_sections,
|
||||
self.section_draws,
|
||||
self.section_draw_calls,
|
||||
) = (sections, draws, calls);
|
||||
}
|
||||
}
|
||||
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
|
||||
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
|
||||
pub fn stats_lag(&self) -> u64 {
|
||||
self.frame_serial - self.stats_frame
|
||||
}
|
||||
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
|
||||
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
|
||||
pub(super) fn land_frame(&mut self) {
|
||||
let Some(offscreen) = self.deferred.take() else {
|
||||
return;
|
||||
};
|
||||
let submission = self.queue.landed();
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.submitted(&self.queue);
|
||||
timer.collect(&self.device, &mut self.gpu_times);
|
||||
}
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some(timer) = &mut self.own_timing {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if offscreen && let Some(submission) = submission {
|
||||
self.in_flight.push_back(submission);
|
||||
if !self.paced_elsewhere {
|
||||
self.wait_frames();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
pub fn wait_in_flight(&mut self) {
|
||||
self.land_frame();
|
||||
self.wait_frames();
|
||||
}
|
||||
/// The frame on the submit thread submitted: a surface reconfigure waits for the
|
||||
/// device and breaks if a frame is submitted while it waits, and the compositor
|
||||
/// copies views after them. (The end of a frame already lands the frames of the
|
||||
/// turn before; this is the turn's own.)
|
||||
pub fn wait_submissions(&mut self) {
|
||||
self.queue.wait();
|
||||
}
|
||||
pub(super) fn wait_frames(&mut self) {
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: index,
|
||||
timeout: Some(std::time::Duration::from_secs(5)),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
pub(super) fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
|
||||
// Other 16-bit frames keep their clipped 8-bit copy.
|
||||
let copy = self
|
||||
.hdr
|
||||
.as_mut()
|
||||
.and_then(|hdr| {
|
||||
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
|
||||
})
|
||||
.or_else(|| {
|
||||
(texture.format() != format && texture.format() == self.output.frame.format)
|
||||
.then(|| {
|
||||
self.readback
|
||||
.unorm_copy(&self.device, encoder, texture, format)
|
||||
})
|
||||
});
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
let error = format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
);
|
||||
if request.background {
|
||||
self.saved_screenshots.push(screenshot_save::Saved {
|
||||
path: request.path,
|
||||
result: Err(error),
|
||||
hdr: None,
|
||||
});
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(error));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! The camera's view frustum, as camera-relative clip planes.
|
||||
|
||||
use glam::{Vec3, Vec4};
|
||||
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
pub(super) planes: [Vec4; 6],
|
||||
}
|
||||
impl Frustum {
|
||||
/// Whether the camera-relative box `min..max` is at least partly inside.
|
||||
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
!self.planes.iter().any(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether a camera-relative sphere is at least partly inside. Frustum
|
||||
/// planes are normalized, so each test needs only one dot product and a
|
||||
/// radius comparison.
|
||||
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
|
||||
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
|
||||
return true;
|
||||
}
|
||||
!self
|
||||
.planes
|
||||
.iter()
|
||||
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
|
||||
}
|
||||
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
|
||||
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
|
||||
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
|
||||
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
|
||||
return true;
|
||||
}
|
||||
!self.planes[4..].iter().any(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether the camera-relative box `min..max` is entirely inside. Then
|
||||
/// `intersects` accepts every box within it: each plane's distance only
|
||||
/// grows from this box's nearest corner to the inner box's farthest, and
|
||||
/// rounding keeps that order.
|
||||
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
self.planes.iter().all(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, min, max);
|
||||
p.truncate().dot(v) + p.w >= 0.0
|
||||
})
|
||||
}
|
||||
/// Whether the camera-relative `point` is on or inside every plane (vanilla's
|
||||
/// `Frustum.pointInFrustum`). A point that is not finite is kept: a test that cannot
|
||||
/// tell must not hide the particle.
|
||||
pub fn contains_point(&self, point: Vec3) -> bool {
|
||||
if !point.is_finite() {
|
||||
return true;
|
||||
}
|
||||
self.planes
|
||||
.iter()
|
||||
.all(|plane| plane.truncate().dot(point) + plane.w >= 0.0)
|
||||
}
|
||||
/// The same frustum seen from a camera moved `blocks` back along the view direction
|
||||
/// (vanilla's `Frustum.offset`): the sides open up near the camera and the near plane
|
||||
/// moves behind it, so what sits just behind or beside the camera stays inside.
|
||||
///
|
||||
/// The view direction is the normal of the depth planes (the two are opposite), signed
|
||||
/// towards where the side planes point. A frustum that has no direction to pull along
|
||||
/// comes back unchanged: a box with no depth planes (the shadow pass's), and a box whose
|
||||
/// sides are parallel to the view direction (an orthographic projection), where the side
|
||||
/// normals cancel and say nothing about which way is forward.
|
||||
pub fn pulled_back(&self, blocks: f32) -> Frustum {
|
||||
let sides: Vec3 = self.planes[..4].iter().map(|p| p.truncate()).sum();
|
||||
let depth = self.planes[4].truncate();
|
||||
let along = depth.dot(sides);
|
||||
// A perspective frustum's four side normals each lean forward, so this is well above 1.
|
||||
if along.abs() < 1.0e-3 {
|
||||
return *self;
|
||||
}
|
||||
let toward_sides = if along > 0.0 { depth } else { -depth };
|
||||
let axis = toward_sides.normalize_or_zero();
|
||||
let mut planes = self.planes;
|
||||
for plane in &mut planes {
|
||||
plane.w += blocks * plane.truncate().dot(axis);
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
/// The frustum of a camera-relative `view_proj`, for tests outside this module.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_view_projection(view_proj: glam::Mat4) -> Frustum {
|
||||
Frustum {
|
||||
planes: super::frustum(view_proj),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Frustum, Vec3};
|
||||
use crate::gfx::world_projection;
|
||||
use glam::Mat4;
|
||||
|
||||
const FOV: f32 = 70.0;
|
||||
const ASPECT: f32 = 16.0 / 9.0;
|
||||
const FAR: f32 = 512.0;
|
||||
const EPS: f32 = 0.01;
|
||||
|
||||
fn looking_down_minus_z() -> Frustum {
|
||||
Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR))
|
||||
}
|
||||
|
||||
fn tangents() -> (f32, f32) {
|
||||
let up = (FOV / 2.0).to_radians().tan();
|
||||
(up * ASPECT, up)
|
||||
}
|
||||
|
||||
/// Each of the six planes of the unmoved frustum, a point just inside and one just outside,
|
||||
/// from the field of view worked out by hand.
|
||||
#[test]
|
||||
fn a_point_is_inside_only_within_all_six_planes() {
|
||||
let view = looking_down_minus_z();
|
||||
let (tan_x, tan_y) = tangents();
|
||||
let depth = 10.0;
|
||||
let inside = [
|
||||
Vec3::new(0.0, 0.0, -depth),
|
||||
Vec3::new(depth * tan_x - EPS, 0.0, -depth),
|
||||
Vec3::new(-(depth * tan_x - EPS), 0.0, -depth),
|
||||
Vec3::new(0.0, depth * tan_y - EPS, -depth),
|
||||
Vec3::new(0.0, -(depth * tan_y - EPS), -depth),
|
||||
Vec3::new(0.0, 0.0, -0.06),
|
||||
Vec3::new(0.0, 0.0, -(FAR - 0.1)),
|
||||
];
|
||||
let outside = [
|
||||
Vec3::new(depth * tan_x + EPS, 0.0, -depth),
|
||||
Vec3::new(-(depth * tan_x + EPS), 0.0, -depth),
|
||||
Vec3::new(0.0, depth * tan_y + EPS, -depth),
|
||||
Vec3::new(0.0, -(depth * tan_y + EPS), -depth),
|
||||
Vec3::new(0.0, 0.0, -0.04),
|
||||
Vec3::new(0.0, 0.0, -(FAR + 0.1)),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
];
|
||||
for p in inside {
|
||||
assert!(view.contains_point(p), "{p} should be inside");
|
||||
}
|
||||
for p in outside {
|
||||
assert!(!view.contains_point(p), "{p} should be outside");
|
||||
}
|
||||
assert!(view.contains_point(Vec3::NAN), "an unknown point is kept");
|
||||
}
|
||||
|
||||
/// Camera 3 blocks back: its apex is at z = 3, so the sides open by 3 * tan, the near
|
||||
/// plane is at z = 3 - 0.05 and the far plane at z = 3 - FAR.
|
||||
#[test]
|
||||
fn pulling_the_camera_back_moves_every_plane() {
|
||||
let (tan_x, tan_y) = tangents();
|
||||
let back = looking_down_minus_z().pulled_back(3.0);
|
||||
let inside = [
|
||||
Vec3::new(0.0, 0.0, 2.9),
|
||||
Vec3::new(3.0 * tan_x - EPS, 0.0, 0.0),
|
||||
Vec3::new(-(3.0 * tan_x - EPS), 0.0, 0.0),
|
||||
Vec3::new(0.0, 3.0 * tan_y - EPS, 0.0),
|
||||
Vec3::new(0.0, -(3.0 * tan_y - EPS), 0.0),
|
||||
Vec3::new(0.0, 0.0, 3.0 - FAR + 0.1),
|
||||
];
|
||||
let outside = [
|
||||
Vec3::new(0.0, 0.0, 3.0),
|
||||
Vec3::new(3.0 * tan_x + EPS, 0.0, 0.0),
|
||||
Vec3::new(-(3.0 * tan_x + EPS), 0.0, 0.0),
|
||||
Vec3::new(0.0, 3.0 * tan_y + EPS, 0.0),
|
||||
Vec3::new(0.0, -(3.0 * tan_y + EPS), 0.0),
|
||||
Vec3::new(0.0, 0.0, 3.0 - FAR - 0.1),
|
||||
];
|
||||
for p in inside {
|
||||
assert!(back.contains_point(p), "{p} should be inside");
|
||||
}
|
||||
for p in outside {
|
||||
assert!(!back.contains_point(p), "{p} should be outside");
|
||||
}
|
||||
// The unmoved frustum does not have the point that only the offset one has.
|
||||
assert!(!looking_down_minus_z().contains_point(Vec3::new(0.0, 0.0, 2.9)));
|
||||
// An offset of nothing changes no verdict.
|
||||
let same = looking_down_minus_z();
|
||||
let unmoved = same.pulled_back(0.0);
|
||||
for p in [
|
||||
Vec3::new(1.0, 2.0, -3.0),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
Vec3::new(40.0, 0.0, -5.0),
|
||||
] {
|
||||
assert_eq!(same.contains_point(p), unmoved.contains_point(p));
|
||||
}
|
||||
}
|
||||
|
||||
/// The offset follows the camera's own view direction, whatever way it looks.
|
||||
#[test]
|
||||
fn the_offset_is_along_the_view_direction() {
|
||||
for (yaw, pitch) in [(0.3_f32, 0.0_f32), (2.0, -0.5), (-1.2, 0.9), (3.0, 1.2)] {
|
||||
let pose = Mat4::from_rotation_x(pitch) * Mat4::from_rotation_y(yaw);
|
||||
let view = Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR) * pose);
|
||||
let forward = pose.inverse().transform_vector3(-Vec3::Z);
|
||||
let back = view.pulled_back(3.0);
|
||||
assert!(
|
||||
back.contains_point(forward * -2.9),
|
||||
"{yaw} {pitch}: 2.9 behind"
|
||||
);
|
||||
assert!(
|
||||
!back.contains_point(forward * -3.0),
|
||||
"{yaw} {pitch}: 3 behind"
|
||||
);
|
||||
assert!(
|
||||
!view.contains_point(forward * -2.9),
|
||||
"{yaw} {pitch}: unmoved"
|
||||
);
|
||||
assert!(back.contains_point(forward * 20.0) && view.contains_point(forward * 20.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// An orthographic box has no apex to pull back from: its side normals cancel, so there is
|
||||
/// no direction, and the frustum must come back as it was.
|
||||
#[test]
|
||||
fn an_orthographic_frustum_stays_where_it_is() {
|
||||
let ortho =
|
||||
Frustum::from_view_projection(Mat4::orthographic_rh(-8.0, 8.0, -4.5, 4.5, 100.0, 0.05));
|
||||
let moved = ortho.pulled_back(3.0);
|
||||
for p in [
|
||||
Vec3::new(0.0, 0.0, 2.0),
|
||||
Vec3::new(0.0, 0.0, -9.0),
|
||||
Vec3::new(7.9, 4.4, -9.0),
|
||||
Vec3::new(8.1, 0.0, -9.0),
|
||||
Vec3::new(0.0, 0.0, -101.0),
|
||||
] {
|
||||
assert_eq!(ortho.contains_point(p), moved.contains_point(p), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The shadow pass's box has no depth planes, so no direction: it must not move.
|
||||
#[test]
|
||||
fn a_frustum_without_depth_planes_stays_where_it_is() {
|
||||
let mut flat = looking_down_minus_z();
|
||||
flat.planes[4] = glam::Vec4::W;
|
||||
flat.planes[5] = glam::Vec4::W;
|
||||
let moved = flat.pulled_back(3.0);
|
||||
for p in [
|
||||
Vec3::new(0.0, 0.0, 2.0),
|
||||
Vec3::new(0.0, 0.0, -9.0),
|
||||
Vec3::new(80.0, 0.0, -9.0),
|
||||
] {
|
||||
assert_eq!(flat.contains_point(p), moved.contains_point(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -853,7 +853,7 @@ impl H {
|
||||
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
|
||||
let g = &s.horizon;
|
||||
match self {
|
||||
H::Preset => labelled("Preset", g.preset.label()),
|
||||
H::Preset => labelled("Horizon Preset", g.preset.label()),
|
||||
H::LodDistance => match g.lod_distance {
|
||||
0 => labelled("LOD Distance", "Off"),
|
||||
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
|
||||
@@ -1753,6 +1753,14 @@ mod tests {
|
||||
ui.set_search("tearing");
|
||||
let found = ui.listed(&s, SIZE);
|
||||
assert!(found.iter().any(|(_, i)| *i == Item::Vanilla(Opt::Vsync)));
|
||||
// Both presets answer "preset"; Horizon's own, named apart from vanilla's, answers its name.
|
||||
ui.set_search("preset");
|
||||
let found = ui.listed(&s, SIZE);
|
||||
let general = |item| (Page::General, item);
|
||||
assert!(found.contains(&general(Item::Horizon(H::Preset))));
|
||||
assert!(found.contains(&general(Item::Vanilla(Opt::Preset))));
|
||||
ui.set_search("horizon preset");
|
||||
assert_eq!(ui.listed(&s, SIZE), [general(Item::Horizon(H::Preset))]);
|
||||
ui.set_search("hdr");
|
||||
let [x, y, w, h] = cols.page(Page::ALL.len() - 1);
|
||||
press(&mut ui, &mut s, (x + w / 2.0, y + h / 2.0));
|
||||
|
||||
@@ -55,7 +55,7 @@ pub struct TextureFlags {
|
||||
impl TextureFlags {
|
||||
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
|
||||
pub fn from_mcmeta(json: &str) -> Option<Self> {
|
||||
let meta = crate::packs::first_json(json.as_bytes())?;
|
||||
let meta: serde_json::Value = crate::packs::first_json(json.as_bytes()).ok()?;
|
||||
let section = meta.get("texture")?;
|
||||
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
|
||||
let flags = Self {
|
||||
@@ -388,7 +388,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
|
||||
}
|
||||
/// The `animation` section of a texture's `.mcmeta`.
|
||||
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
|
||||
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
|
||||
let meta: serde_json::Value = crate::packs::first_json(mcmeta?.as_bytes()).ok()?;
|
||||
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
|
||||
}
|
||||
/// An animated strip's first frame: the region the mcmeta's frame size picks
|
||||
|
||||
@@ -1594,9 +1594,9 @@ pub struct OptionsUi {
|
||||
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
|
||||
/// Shaders: the shader packs and the picked one's options, on the settings shell.
|
||||
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
|
||||
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
|
||||
/// fills them beside `pack_infos`).
|
||||
pub pack_options: crate::pack_options_ui::PackOptionsUi,
|
||||
/// Pack Options, on the settings shell, and the packs' Polytone config entries by pack id
|
||||
/// (the game fills them beside `pack_infos`).
|
||||
pack_options: shell::Shell<crate::pack_options_ui::PackOptionsScreen>,
|
||||
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
|
||||
pub split_profiles: crate::split_profiles::SplitProfiles,
|
||||
/// HDR calibration while its page is open.
|
||||
@@ -3401,10 +3401,6 @@ impl OptionsUi {
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
let waiting = self.waiting();
|
||||
let in_world = self.in_world;
|
||||
if self.draw_radial_name(batch, atlas, cursor, in_world) {
|
||||
@@ -3786,20 +3782,6 @@ impl OptionsUi {
|
||||
});
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if self.page() == Page::PackOptions {
|
||||
let mut events = Vec::new();
|
||||
for action in self
|
||||
.pack_options
|
||||
.click((x, y), size, drag, right || self.shift)
|
||||
{
|
||||
match action {
|
||||
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
crate::pack_options_ui::Action::SliderDrag => {}
|
||||
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
let mut events = Vec::new();
|
||||
if drag {
|
||||
@@ -3829,14 +3811,7 @@ impl OptionsUi {
|
||||
crate::pack_screen::Action::BrowsePick(i) => {
|
||||
events.push(Event::DownloadPack(i));
|
||||
}
|
||||
crate::pack_screen::Action::Options(id) => {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
if let Some(own) = self.pack_configs.get(&id) {
|
||||
configs.merge(own);
|
||||
}
|
||||
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
|
||||
self.push(Page::PackOptions);
|
||||
}
|
||||
crate::pack_screen::Action::Options(id) => self.open_pack_options(&id),
|
||||
crate::pack_screen::Action::None => {}
|
||||
}
|
||||
return events;
|
||||
@@ -4074,7 +4049,6 @@ impl OptionsUi {
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
|
||||
self.pack_options.release();
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || shell_slider.is_some() || calibration_slider {
|
||||
@@ -4168,10 +4142,6 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.scroll(size, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
let plain = self.resource_error.is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
@@ -4255,10 +4225,6 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.scroll(size, amount);
|
||||
return;
|
||||
}
|
||||
match &mut self.pack_screen {
|
||||
Some(p) if self.stack.last() == Some(&Page::Packs) => {
|
||||
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
|
||||
@@ -4393,14 +4359,8 @@ impl OptionsUi {
|
||||
self.can_change_value = false;
|
||||
match page {
|
||||
Page::PackOptions => {
|
||||
let ui = std::mem::take(&mut self.pack_options);
|
||||
if ui.changed {
|
||||
let entries = ui.configs.entries().keys().cloned().collect();
|
||||
events.push(Event::PackOptions {
|
||||
entries,
|
||||
chosen: ui.configs.chosen(),
|
||||
});
|
||||
}
|
||||
let screen = std::mem::take(&mut self.pack_options.screen);
|
||||
events.extend(screen.finished());
|
||||
}
|
||||
Page::ShaderOptions | Page::ShaderPacks => {
|
||||
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
|
||||
@@ -4960,6 +4920,34 @@ mod tests {
|
||||
/// A renderer whose destination offers nothing and whose display reports nothing.
|
||||
const NONE: EffectiveOutput = EffectiveOutput::NONE;
|
||||
|
||||
#[test]
|
||||
fn pack_options_leave_with_a_change_and_with_nothing_after_its_undo() {
|
||||
use crate::settings_shell::Screen as _;
|
||||
let mut s = Settings::default();
|
||||
let atlas = HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"));
|
||||
let atlas = atlas.unwrap();
|
||||
let mut ui = OptionsUi::default();
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
let birds = serde_json::json!({"default_value": true});
|
||||
configs.add_entry("p:birds".into(), &birds);
|
||||
ui.pack_configs.insert("pack".into(), configs);
|
||||
for undone in [false, true] {
|
||||
ui.open_pack_options("pack");
|
||||
assert!(ui.shell().is_some(), "Pack Options is on the shell");
|
||||
let birds = crate::pack_options_ui::Item::Entry(0);
|
||||
ui.pack_options.screen.press(&mut s, birds, false);
|
||||
if undone {
|
||||
let before = s.clone();
|
||||
ui.pack_options.screen.undo(&mut s, &before);
|
||||
}
|
||||
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None);
|
||||
let sent = events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Event::PackOptions { .. }));
|
||||
assert_eq!(sent, !undone, "undone: {undone}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The window allows the input method only while `typing` says a search
|
||||
/// box has the focus (`Game::text_field_focused`), so a page without one
|
||||
/// must not claim the keys: the movement and menu keys keep working there.
|
||||
|
||||
@@ -23,6 +23,7 @@ impl OptionsUi {
|
||||
Page::ControllerRadialPick => Some(&self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
Page::PackOptions => Some(&self.pack_options),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -35,9 +36,20 @@ impl OptionsUi {
|
||||
Page::ControllerRadialPick => Some(&mut self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
Page::PackOptions => Some(&mut self.pack_options),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Pack Options for the pack `id`, from the pack screen: its Polytone config entries.
|
||||
pub(super) fn open_pack_options(&mut self, id: &str) {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
if let Some(own) = self.pack_configs.get(id) {
|
||||
configs.merge(own);
|
||||
}
|
||||
// Opening a shell starts it afresh: the entries go in after.
|
||||
self.push(Page::PackOptions);
|
||||
self.pack_options.screen = crate::pack_options_ui::PackOptionsScreen::open(configs);
|
||||
}
|
||||
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
|
||||
/// action), for a controller's focus to land there.
|
||||
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
|
||||
@@ -53,13 +65,14 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
// Every shell: the click may have changed the page.
|
||||
let shells: [&mut dyn shell::Hosted; 6] = [
|
||||
let shells: [&mut dyn shell::Hosted; 7] = [
|
||||
&mut self.extras,
|
||||
&mut self.graphics,
|
||||
&mut self.controller,
|
||||
&mut self.radial,
|
||||
&mut self.radial_pick,
|
||||
&mut self.shader,
|
||||
&mut self.pack_options,
|
||||
];
|
||||
for shell in shells {
|
||||
shell.clicks_step(false);
|
||||
|
||||
@@ -1,45 +1,36 @@
|
||||
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
|
||||
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
|
||||
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
|
||||
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
|
||||
//! and the packs load again with it.
|
||||
use crate::widgets::Action as Declared;
|
||||
//! The Pack Options screen (#11486), on the settings shell (`settings_shell`): the config entries
|
||||
//! of the selected resource packs' Polytone `config_entries` (wiki "Polytone Configs"). Each
|
||||
//! `section` is a page, named by the packs' own translations; numbers are sliders that stop at
|
||||
//! the entry's `step`, value lists are choices, booleans switch and free text reads. The first
|
||||
//! page starts with the pack-wide presets and Reset all. The shell gives it the keyboard, a
|
||||
//! controller's focus, the search, the strip's descriptions and Undo; leaving keeps what differs
|
||||
//! from how the screen opened, and the packs load again with it.
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
settings_shell::{self as shell, Columns, Line, Lines},
|
||||
text::Text,
|
||||
ui,
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Group, Output, Row, Sheet, Value},
|
||||
};
|
||||
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
|
||||
|
||||
/// What a click asks of the options screen.
|
||||
/// A row: the pack-wide presets, Reset all, or the entry at an index of `ids`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
ButtonClick,
|
||||
SliderDrag,
|
||||
/// Done: leave, keeping what changed.
|
||||
Done,
|
||||
pub(crate) enum Item {
|
||||
Presets,
|
||||
ResetAll,
|
||||
Entry(u16),
|
||||
}
|
||||
|
||||
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
|
||||
#[derive(Default)]
|
||||
pub struct PackOptionsUi {
|
||||
/// The selected packs' entries and the values being picked.
|
||||
pub configs: Configs,
|
||||
/// Entry ids, by the row index the list uses.
|
||||
ids: Vec<String>,
|
||||
/// The sections in order: the `section` id (none for entries without one) and its rows.
|
||||
sections: Vec<(Option<String>, Vec<u16>)>,
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<u16>,
|
||||
/// A value changed since the page opened.
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// The presets' row (and its section): past every entry's index.
|
||||
const PRESETS: u16 = u16::MAX;
|
||||
const TITLE: &str = "horizon.pack.options.title";
|
||||
/// The first page's group of rows for the whole pack, and every page's group of entries.
|
||||
const WHOLE_PACK: &str = "Whole pack";
|
||||
const PACK_OPTIONS: &str = "Pack settings";
|
||||
const RESET_ALL: &str = "Reset all";
|
||||
const RESET_ABOUT: &str = "Puts every option of this pack back to its default.";
|
||||
const AT_DEFAULTS: &str = "Every option is at its default already.";
|
||||
const PRESETS_ABOUT: &str = "Sets many options at once, as the pack groups them.";
|
||||
const PACK_SETTINGS: &str = "Settings made by the pack's author: they change only this pack.";
|
||||
/// A pack's tooltips, with the performance line after them, run longer than Horizon's own.
|
||||
const NOTE_LINES: usize = 3;
|
||||
|
||||
/// A translation, or `fallback` where no language has the key.
|
||||
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
|
||||
@@ -65,9 +56,34 @@ fn readable(path: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl PackOptionsUi {
|
||||
/// The page opens on `configs`, the selected packs' entries with the player's values.
|
||||
pub fn open(configs: Configs) -> Self {
|
||||
/// A number as its entry shows it: whole with a whole step, else up to two decimals.
|
||||
fn number_text(n: f64, step: f64) -> String {
|
||||
if step >= 1.0 && step.fract() == 0.0 {
|
||||
format!("{n:.0}")
|
||||
} else {
|
||||
let text = format!("{n:.2}");
|
||||
text.trim_end_matches('0').trim_end_matches('.').to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen: the entries being edited, as they were when it opened (for Undo and for whether
|
||||
/// leaving changes anything), and how they are paged.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PackOptionsScreen {
|
||||
/// The selected packs' entries and the values being picked.
|
||||
pub(crate) configs: Configs,
|
||||
opened: Configs,
|
||||
/// Entry ids, by `Item::Entry`'s index.
|
||||
ids: Vec<String>,
|
||||
/// The sections in order: the `section` id (none for entries without one) and its entries.
|
||||
sections: Vec<(Option<String>, Vec<u16>)>,
|
||||
/// Bumped on every change, for `stamp`: the values are floats, which don't hash.
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
impl PackOptionsScreen {
|
||||
/// The screen opens on `configs`, the selected packs' entries with the player's values.
|
||||
pub(crate) fn open(configs: Configs) -> Self {
|
||||
let mut ids: Vec<(String, Option<String>, i64)> = configs
|
||||
.entries()
|
||||
.iter()
|
||||
@@ -96,25 +112,21 @@ impl PackOptionsUi {
|
||||
(None, None) => a.0.cmp(&b.0),
|
||||
});
|
||||
Self {
|
||||
opened: configs.clone(),
|
||||
configs,
|
||||
ids: ids.into_iter().map(|(id, ..)| id).collect(),
|
||||
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
|
||||
..Self::default()
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
|
||||
let cols = Columns::of(w, h);
|
||||
let mut lines = Lines::new();
|
||||
// The pack-wide presets first, when the entries have some.
|
||||
if !self.configs.presets().is_empty() {
|
||||
lines.titled(PRESETS, vec![PRESETS]);
|
||||
}
|
||||
for (i, (_, rows)) in self.sections.iter().enumerate() {
|
||||
lines.titled(i as u16, rows.clone());
|
||||
}
|
||||
let (lines, content) = lines.finish();
|
||||
(cols, lines, content)
|
||||
/// What leaving sends: the entries and the player's choices, if they differ from how the
|
||||
/// screen opened (an Undo, or a value stepped back, leaves nothing to send).
|
||||
pub(crate) fn finished(&self) -> Option<crate::options_ui::Event> {
|
||||
(self.configs != self.opened).then(|| crate::options_ui::Event::PackOptions {
|
||||
entries: self.configs.entries().keys().cloned().collect(),
|
||||
chosen: self.configs.chosen(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The namespace the presets' translations are under: of the first entry with presets.
|
||||
@@ -166,21 +178,14 @@ impl PackOptionsUi {
|
||||
None if back => n - 1,
|
||||
None => 0,
|
||||
};
|
||||
let before = self.configs.clone();
|
||||
self.configs.apply_preset(&presets[next]);
|
||||
if self.configs != before {
|
||||
self.changed = true;
|
||||
}
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
|
||||
fn section_name(&self, section: u16) -> String {
|
||||
if section == PRESETS {
|
||||
let key = format!("{}.presets", self.presets_namespace());
|
||||
return translated(&key, || lang::get("horizon.pack.options.presets"));
|
||||
}
|
||||
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
|
||||
return String::new();
|
||||
/// A section's page name: `config.<namespace>.section.<id>`, by its first entry's namespace.
|
||||
fn section_name(&self, section: usize) -> String {
|
||||
let Some((id, rows)) = self.sections.get(section) else {
|
||||
return lang::get("horizon.pack.options.general");
|
||||
};
|
||||
let Some(id) = id else {
|
||||
return lang::get("horizon.pack.options.general");
|
||||
@@ -192,367 +197,218 @@ impl PackOptionsUi {
|
||||
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
|
||||
}
|
||||
|
||||
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
|
||||
fn parts(&self, row: u16) -> (String, Option<String>) {
|
||||
if row == PRESETS {
|
||||
let label = self.preset_label(self.current_preset().as_deref());
|
||||
return (self.section_name(PRESETS), Some(label));
|
||||
}
|
||||
let Some(id) = self.ids.get(usize::from(row)) else {
|
||||
return (String::new(), None);
|
||||
};
|
||||
let (namespace, path) = split(id);
|
||||
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
|
||||
let entry = &self.configs.entries()[id];
|
||||
let value = match self.configs.get(id) {
|
||||
Some(ConfigValue::Number(n)) => {
|
||||
let step = match entry.kind {
|
||||
ConfigKind::Number { step, .. } => step,
|
||||
_ => 1.0,
|
||||
};
|
||||
let text = if step >= 1.0 && step.fract() == 0.0 {
|
||||
format!("{n:.0}")
|
||||
} else {
|
||||
format!("{n:.2}")
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_owned()
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
Some(ConfigValue::Text(t)) => Some(t.clone()),
|
||||
Some(ConfigValue::Bool(_)) | None => None,
|
||||
};
|
||||
let value = value.map(|v| match &entry.value_translation {
|
||||
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
|
||||
_ => v,
|
||||
});
|
||||
(name, value)
|
||||
}
|
||||
|
||||
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
|
||||
fn description(&self, row: u16) -> String {
|
||||
let Some(id) = self.ids.get(usize::from(row)) else {
|
||||
return String::new();
|
||||
};
|
||||
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance line after it.
|
||||
fn description(&self, id: &str) -> String {
|
||||
let (namespace, path) = split(id);
|
||||
let key = format!("config.{namespace}.{path}.tooltip");
|
||||
let mut text = if lang::has(&key) {
|
||||
let tooltip = if lang::has(&key) {
|
||||
lang::get(&key)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if let Some(impact) = &self.configs.entries()[id].performance_impact {
|
||||
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
|
||||
let line = lang::tr("horizon.pack.options.impact", &[&level]);
|
||||
text = if text.is_empty() {
|
||||
line
|
||||
} else {
|
||||
format!("{text} {line}")
|
||||
};
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A slider's place (0 to 1) for a number entry.
|
||||
fn slider(&self, row: u16) -> Option<f32> {
|
||||
let id = self.ids.get(usize::from(row))?;
|
||||
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
|
||||
return None;
|
||||
};
|
||||
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
|
||||
return None;
|
||||
};
|
||||
Some(if max > min {
|
||||
((n - min) / (max - min)) as f32
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
}
|
||||
|
||||
fn switch(&self, row: u16) -> Option<bool> {
|
||||
match self.configs.get(self.ids.get(usize::from(row))?)? {
|
||||
ConfigValue::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
let impact = self.configs.entries()[id]
|
||||
.performance_impact
|
||||
.as_ref()
|
||||
.map(|impact| {
|
||||
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
|
||||
lang::tr("horizon.pack.options.impact", &[&level])
|
||||
});
|
||||
match impact {
|
||||
Some(line) if tooltip.is_empty() => line,
|
||||
Some(line) => format!("{tooltip} {line}"),
|
||||
None => tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let (cols, lines, content) = self.layout(w, h);
|
||||
self.scroll.clamp(cols.top, cols.bottom, content);
|
||||
shell::backdrop(batch, atlas, in_world);
|
||||
shell::mark(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lang::get("horizon.pack.options.title"),
|
||||
);
|
||||
shell::plate(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lang::get("horizon.pack.options.title"),
|
||||
);
|
||||
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
|
||||
let mut described = None;
|
||||
shell::list(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lines,
|
||||
self.scroll.amount,
|
||||
|section| self.section_name(section),
|
||||
|batch, row: u16, y| {
|
||||
let [rx, ry, rw, rh] = cols.row(y);
|
||||
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
|
||||
if hover || self.dragging == Some(row) {
|
||||
described = Some(row);
|
||||
/// Entry `i`'s row: a switch, a slider in its steps, a choice, or text to read.
|
||||
fn entry_row(&self, i: u16) -> Option<Row<Item>> {
|
||||
let id = self.ids.get(usize::from(i))?;
|
||||
let (namespace, path) = split(id);
|
||||
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
|
||||
let entry = &self.configs.entries()[id];
|
||||
let shown = |text: String| match &entry.value_translation {
|
||||
Some(key) if lang::has(key) => lang::tr(key, &[&text]),
|
||||
_ => text,
|
||||
};
|
||||
let value = match (&entry.kind, self.configs.get(id)?) {
|
||||
(_, ConfigValue::Bool(on)) => Value::Switch(*on),
|
||||
(&ConfigKind::Number { min, max, step }, &ConfigValue::Number(n)) => {
|
||||
let span = max - min;
|
||||
let stops = if step > 0.0 {
|
||||
(span / step).round()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Value::Slider {
|
||||
position: if span > 0.0 {
|
||||
((n - min) / span) as f32
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
steps: (stops >= 1.0).then_some(stops as u32),
|
||||
text: shown(number_text(n, step)),
|
||||
}
|
||||
let (name, value) = self.parts(row);
|
||||
let label = match &value {
|
||||
Some(v) => format!("{name}: {v}"),
|
||||
None => name.clone(),
|
||||
};
|
||||
let view = shell::RowView {
|
||||
label: &label,
|
||||
name: &name,
|
||||
value: value.as_deref(),
|
||||
switch: self.switch(row),
|
||||
slider: self.slider(row),
|
||||
live: true,
|
||||
hover,
|
||||
focused: false,
|
||||
held: self.dragging == Some(row),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, &cols, y, &view);
|
||||
},
|
||||
);
|
||||
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
|
||||
// The strip: the pointed-at entry's tooltip.
|
||||
let [x, y, ..] = cols.dock;
|
||||
style::panel(batch, atlas, cols.dock);
|
||||
if let Some(row) = described {
|
||||
let name = Text::literal(self.parts(row).0);
|
||||
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
|
||||
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
|
||||
}
|
||||
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
|
||||
if self
|
||||
.configs
|
||||
.chosen()
|
||||
.iter()
|
||||
.any(|(id, _)| self.ids.contains(id))
|
||||
{
|
||||
foot.push((cols.button(1), "controls.reset", None));
|
||||
}
|
||||
for (rect, key, declared) in foot {
|
||||
if let Some(declared) = declared {
|
||||
crate::widgets::declare_action(declared, rect, true);
|
||||
}
|
||||
let label = Text::literal(lang::get(key));
|
||||
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
|
||||
}
|
||||
(ConfigKind::Text { allowed }, ConfigValue::Text(t)) if !allowed.is_empty() => {
|
||||
Value::Choice(shown(t.clone()))
|
||||
}
|
||||
(_, value) => Value::Read(shown(match value {
|
||||
ConfigValue::Number(n) => number_text(*n, 1.0),
|
||||
ConfigValue::Text(t) => t.clone(),
|
||||
ConfigValue::Bool(b) => b.to_string(),
|
||||
})),
|
||||
};
|
||||
Some(Row::new(Item::Entry(i), name, value).about(self.description(id)))
|
||||
}
|
||||
|
||||
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
|
||||
pub fn click(
|
||||
&mut self,
|
||||
(x, y): (f32, f32),
|
||||
(w, h): (f32, f32),
|
||||
drag: bool,
|
||||
back: bool,
|
||||
) -> Vec<Action> {
|
||||
let (cols, lines, content) = self.layout(w, h);
|
||||
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
|
||||
if drag {
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(y, cols.top, cols.bottom, content);
|
||||
} else if let Some(row) = self.dragging {
|
||||
self.set_slider(row, cols.slider_at(x));
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
return Vec::new();
|
||||
/// The rows for the whole pack: its presets, when it has some, and Reset all.
|
||||
fn whole_pack(&self) -> Group<Item> {
|
||||
let mut rows = Vec::new();
|
||||
if !self.configs.presets().is_empty() {
|
||||
let key = format!("{}.presets", self.presets_namespace());
|
||||
let name = translated(&key, || lang::get("horizon.pack.options.presets"));
|
||||
let label = self.preset_label(self.current_preset().as_deref());
|
||||
rows.push(
|
||||
Row::new(Item::Presets, name, Value::Choice(label)).about(lang::get(PRESETS_ABOUT)),
|
||||
);
|
||||
}
|
||||
if cols.scrolls()
|
||||
&& self
|
||||
.scroll
|
||||
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
|
||||
{
|
||||
return Vec::new();
|
||||
let at_defaults = self.configs.chosen().is_empty();
|
||||
let reset = Row::new(Item::ResetAll, lang::get(RESET_ALL), Value::Press)
|
||||
.about(lang::get(RESET_ABOUT))
|
||||
.blocked(at_defaults.then(|| lang::get(AT_DEFAULTS)));
|
||||
rows.push(reset);
|
||||
Group {
|
||||
name: WHOLE_PACK,
|
||||
rows,
|
||||
}
|
||||
if at(cols.button(0)) {
|
||||
return vec![Action::ButtonClick, Action::Done];
|
||||
}
|
||||
if at(cols.button(1)) {
|
||||
self.reset();
|
||||
return vec![Action::ButtonClick];
|
||||
}
|
||||
if y < cols.top || y >= cols.bottom {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(row) = lines.iter().find_map(|line| {
|
||||
let row = line.setting?;
|
||||
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if row == PRESETS {
|
||||
self.cycle_preset(back);
|
||||
return vec![Action::ButtonClick];
|
||||
}
|
||||
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let entry = self.configs.entries()[&id].clone();
|
||||
match (&entry.kind, self.configs.get(&id).cloned()) {
|
||||
(ConfigKind::Number { .. }, _) => {
|
||||
self.dragging = Some(row);
|
||||
self.set_slider(row, cols.slider_at(x));
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
|
||||
self.set(&id, ConfigValue::Bool(!b));
|
||||
}
|
||||
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
|
||||
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
|
||||
let n = allowed.len();
|
||||
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
|
||||
self.set(&id, ConfigValue::Text(allowed[next].clone()));
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
vec![Action::ButtonClick]
|
||||
}
|
||||
|
||||
fn set(&mut self, id: &str, value: ConfigValue) {
|
||||
let before = self.configs.get(id).cloned();
|
||||
self.configs.set(id, value);
|
||||
if self.configs.get(id).cloned() != before {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Number entry `row` at slider place `at` (0 to 1).
|
||||
fn set_slider(&mut self, row: u16, at: f32) {
|
||||
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
|
||||
return;
|
||||
};
|
||||
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
|
||||
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
|
||||
}
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// Every entry back to its default.
|
||||
fn reset(&mut self) {
|
||||
let defaults: Vec<_> = self
|
||||
.configs
|
||||
.entries()
|
||||
.iter()
|
||||
let defaults: Vec<_> = (self.configs.entries().iter())
|
||||
.map(|(id, e)| (id.clone(), e.default.clone()))
|
||||
.collect();
|
||||
for (id, value) in defaults {
|
||||
self.set(&id, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer lets go.
|
||||
pub fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
self.dragging = None;
|
||||
impl shell::Screen for PackOptionsScreen {
|
||||
type Page = u16;
|
||||
type Row = Item;
|
||||
fn title(&self) -> &'static str {
|
||||
TITLE
|
||||
}
|
||||
|
||||
/// The mouse wheel scrolls the list.
|
||||
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
|
||||
let (cols, _, content) = self.layout(w, h);
|
||||
self.scroll
|
||||
.wheel(amount, 18.0, cols.top, cols.bottom, content);
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read only the screen's own entries, which change through it alone.
|
||||
fn stamp(&self, _s: &Settings) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
/// A page per section; the first starts with the rows for the whole pack.
|
||||
fn sheets(&self, _s: &Settings) -> Vec<Sheet<u16, Item>> {
|
||||
let about = lang::get(PACK_SETTINGS);
|
||||
let mut sheets = Vec::new();
|
||||
for (i, (_, entries)) in self.sections.iter().enumerate() {
|
||||
let rows = entries.iter().filter_map(|&e| self.entry_row(e)).collect();
|
||||
let mut groups = vec![Group {
|
||||
name: PACK_OPTIONS,
|
||||
rows,
|
||||
}];
|
||||
if i == 0 {
|
||||
groups.insert(0, self.whole_pack());
|
||||
}
|
||||
sheets.push(Sheet::new(
|
||||
i as u16,
|
||||
self.section_name(i),
|
||||
about.clone(),
|
||||
groups,
|
||||
));
|
||||
}
|
||||
if sheets.is_empty() {
|
||||
let groups = vec![self.whole_pack()];
|
||||
sheets.push(Sheet::new(0, self.section_name(0), about, groups));
|
||||
}
|
||||
sheets
|
||||
}
|
||||
/// A switch flips, a choice and the presets step (back with Shift, or Left), Reset all
|
||||
/// resets.
|
||||
fn press(&mut self, _s: &mut Settings, item: Item, back: bool) -> Vec<Output> {
|
||||
let i = match item {
|
||||
Item::Presets => {
|
||||
self.cycle_preset(back);
|
||||
return Vec::new();
|
||||
}
|
||||
Item::ResetAll => {
|
||||
self.reset();
|
||||
return Vec::new();
|
||||
}
|
||||
Item::Entry(i) => i,
|
||||
};
|
||||
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let kind = self.configs.entries()[&id].kind.clone();
|
||||
match (kind, self.configs.get(&id).cloned()) {
|
||||
(_, Some(ConfigValue::Bool(on))) => self.set(&id, ConfigValue::Bool(!on)),
|
||||
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
|
||||
let at = allowed.iter().position(|a| *a == t).unwrap_or(0);
|
||||
let n = allowed.len();
|
||||
let next = if back { (at + n - 1) % n } else { (at + 1) % n };
|
||||
self.set(&id, ConfigValue::Text(allowed[next].clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
/// A slider moves its entry to the step nearest it (`Configs::set` snaps it).
|
||||
fn slide(&mut self, _s: &mut Settings, item: Item, position: f32) {
|
||||
let Item::Entry(i) = item else { return };
|
||||
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
|
||||
return;
|
||||
};
|
||||
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
|
||||
let value = min + f64::from(position.clamp(0.0, 1.0)) * (max - min);
|
||||
self.set(&id, ConfigValue::Number(value));
|
||||
}
|
||||
}
|
||||
/// With the "Scroll wheel changes settings" extra, the wheel steps a choice and the presets.
|
||||
fn wheel_step(&mut self, s: &mut Settings, item: Item, back: bool) -> Option<Vec<Output>> {
|
||||
let steps = match item {
|
||||
Item::Presets => true,
|
||||
Item::Entry(i) => self
|
||||
.entry_row(i)
|
||||
.is_some_and(|row| matches!(row.value, Value::Choice(_))),
|
||||
Item::ResetAll => false,
|
||||
};
|
||||
if !steps {
|
||||
return None;
|
||||
}
|
||||
let mut outputs = vec![Output::Event(crate::options_ui::Event::ButtonClick)];
|
||||
outputs.extend(self.press(s, item, back));
|
||||
Some(outputs)
|
||||
}
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The entries as the screen opened with them.
|
||||
fn undo(&mut self, _s: &mut Settings, _before: &Settings) -> Vec<Output> {
|
||||
self.configs = self.opened.clone();
|
||||
self.revision += 1;
|
||||
Vec::new()
|
||||
}
|
||||
/// Undo shows exactly while an entry differs from how the screen opened.
|
||||
fn differs(&self, _s: &Settings, _before: &Settings) -> Option<bool> {
|
||||
Some(self.configs != self.opened)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configs() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
c.add_entry(
|
||||
"p:fog".into(),
|
||||
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
|
||||
);
|
||||
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
|
||||
c.add_entry(
|
||||
"p:birds".into(),
|
||||
&serde_json::json!({"default_value": true}),
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_sort_by_their_order_and_the_unsectioned_last() {
|
||||
let ui = PackOptionsUi::open(configs());
|
||||
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
|
||||
assert_eq!(order, [Some("weather"), Some("sky"), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_switch_cycle_and_slide() {
|
||||
let mut ui = PackOptionsUi::open(configs());
|
||||
let size = (480.0, 270.0);
|
||||
let (cols, lines, _) = ui.layout(size.0, size.1);
|
||||
let ids = ui.ids.clone();
|
||||
let point = |id: &str| {
|
||||
let row = ids.iter().position(|i| i == id).unwrap() as u16;
|
||||
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
|
||||
let [x, y, w, h] = cols.row(cols.top + line.y);
|
||||
(x + w - 4.0, y + h / 2.0)
|
||||
};
|
||||
let (birds, rain) = (point("p:birds"), point("p:rain"));
|
||||
ui.click(birds, size, false, false);
|
||||
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
|
||||
ui.click(rain, size, false, false);
|
||||
assert_eq!(
|
||||
ui.configs.get("p:rain"),
|
||||
Some(&ConfigValue::Text("streaks".into()))
|
||||
);
|
||||
// The slider's far right end is its maximum.
|
||||
let fog = point("p:fog");
|
||||
let (tx, tw) = cols.track();
|
||||
ui.click((tx + tw, fog.1), size, false, false);
|
||||
ui.release();
|
||||
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
|
||||
assert!(ui.changed);
|
||||
assert_eq!(ui.configs.chosen().len(), 3);
|
||||
ui.reset();
|
||||
assert!(ui.configs.chosen().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
|
||||
let mut c = Configs::default();
|
||||
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
|
||||
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
|
||||
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
|
||||
let mut ui = PackOptionsUi::open(c);
|
||||
// The defaults are no preset.
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
ui.cycle_preset(false);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
|
||||
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
|
||||
ui.cycle_preset(false);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
|
||||
ui.cycle_preset(true);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
|
||||
// One value off the preset: custom again.
|
||||
ui.set("p:dust", ConfigValue::Number(0.3));
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
assert!(ui.changed);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,281 @@
|
||||
use super::*;
|
||||
use crate::options_ui::Event;
|
||||
use crate::settings_shell::{Columns, Hosted, Screen as _, Shell, Target};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
const SIZE: (f32, f32) = (854.0, 480.0);
|
||||
|
||||
/// Fog (a number) under Sky, rain (a list of values) under Weather, birds (a boolean) in no
|
||||
/// section.
|
||||
fn configs() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
c.add_entry(
|
||||
"p:fog".into(),
|
||||
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
|
||||
);
|
||||
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
|
||||
c.add_entry(
|
||||
"p:birds".into(),
|
||||
&serde_json::json!({"default_value": true}),
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
/// Two entries with the pack-wide presets `fast` and `pretty`.
|
||||
fn with_presets() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
|
||||
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
|
||||
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
|
||||
c
|
||||
}
|
||||
|
||||
fn shell_of(configs: Configs) -> Shell<PackOptionsScreen> {
|
||||
let mut shell = Shell::default();
|
||||
shell.screen = PackOptionsScreen::open(configs);
|
||||
shell
|
||||
}
|
||||
|
||||
/// The row of entry `id`, and the page it is on.
|
||||
fn entry(shell: &Shell<PackOptionsScreen>, id: &str) -> (u16, Item) {
|
||||
let i = shell.screen.ids.iter().position(|e| e == id).unwrap() as u16;
|
||||
let page = shell.screen.sections.iter().position(|s| s.1.contains(&i));
|
||||
(page.unwrap() as u16, Item::Entry(i))
|
||||
}
|
||||
|
||||
fn press(shell: &mut Shell<PackOptionsScreen>, s: &mut Settings, item: Item) -> Vec<Output> {
|
||||
let at = shell.point_at(s, SIZE, item);
|
||||
shell.click(s, at, SIZE, false, false, false)
|
||||
}
|
||||
|
||||
fn value<'a>(shell: &'a Shell<PackOptionsScreen>, id: &str) -> Option<&'a ConfigValue> {
|
||||
shell.screen.configs.get(id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_sort_by_their_order_and_the_unsectioned_last() {
|
||||
let screen = PackOptionsScreen::open(configs());
|
||||
let order: Vec<_> = screen.sections.iter().map(|(s, _)| s.as_deref()).collect();
|
||||
assert_eq!(order, [Some("weather"), Some("sky"), None]);
|
||||
// Each section is a page, the first one holding Reset all before its entries.
|
||||
let sheets = screen.sheets(&Settings::default());
|
||||
let names: Vec<_> = sheets.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["Weather", "Sky", "horizon.pack.options.general"]);
|
||||
let first: Vec<Vec<Item>> = (sheets[0].groups.iter())
|
||||
.map(|g| g.rows.iter().map(|r| r.id).collect())
|
||||
.collect();
|
||||
let rain = screen.ids.iter().position(|e| e == "p:rain").unwrap() as u16;
|
||||
assert_eq!(first, [vec![Item::ResetAll], vec![Item::Entry(rain)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_switch_cycle_and_slide() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(configs());
|
||||
let (page, birds) = entry(&shell, "p:birds");
|
||||
shell.show(page);
|
||||
assert_eq!(
|
||||
press(&mut shell, &mut s, birds)[0],
|
||||
Output::Event(Event::ButtonClick)
|
||||
);
|
||||
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(false)));
|
||||
let (page, rain) = entry(&shell, "p:rain");
|
||||
shell.show(page);
|
||||
press(&mut shell, &mut s, rain);
|
||||
let streaks = ConfigValue::Text("streaks".into());
|
||||
assert_eq!(value(&shell, "p:rain"), Some(&streaks));
|
||||
// The slider's far right end is its maximum.
|
||||
let (page, fog) = entry(&shell, "p:fog");
|
||||
shell.show(page);
|
||||
let at = shell.point_at(&s, SIZE, fog);
|
||||
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
|
||||
shell.click(&mut s, (track + width, at.1), SIZE, false, false, false);
|
||||
shell.release();
|
||||
assert_eq!(value(&shell, "p:fog"), Some(&ConfigValue::Number(1.0)));
|
||||
assert_eq!(shell.screen.configs.chosen().len(), 3);
|
||||
// Reset all, on the first page, puts them all back, and is greyed once they are.
|
||||
shell.show(0);
|
||||
press(&mut shell, &mut s, Item::ResetAll);
|
||||
assert!(shell.screen.configs.chosen().is_empty());
|
||||
let sheets = shell.screen.sheets(&s);
|
||||
assert!(!sheets[0].groups[0].rows[0].live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
|
||||
let mut screen = PackOptionsScreen::open(with_presets());
|
||||
// The defaults are no preset.
|
||||
assert_eq!(screen.current_preset(), None);
|
||||
screen.cycle_preset(false);
|
||||
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
|
||||
assert_eq!(screen.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
|
||||
screen.cycle_preset(false);
|
||||
assert_eq!(screen.current_preset().as_deref(), Some("pretty"));
|
||||
screen.cycle_preset(true);
|
||||
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
|
||||
// One value off the preset: custom again.
|
||||
screen.set("p:dust", ConfigValue::Number(0.3));
|
||||
assert_eq!(screen.current_preset(), None);
|
||||
let presets = &screen.sheets(&Settings::default())[0].groups[0].rows[0];
|
||||
assert_eq!(presets.id, Item::Presets);
|
||||
let custom = lang::get("horizon.pack.options.presets.custom");
|
||||
assert_eq!(presets.value, Value::Choice(custom));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_step_the_presets() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(with_presets());
|
||||
shell.set_focus(Some(Target::Row(Item::Presets)));
|
||||
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
|
||||
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
assert_eq!(shell.screen.current_preset().as_deref(), Some("pretty"));
|
||||
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
|
||||
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
|
||||
assert_eq!(shell.focused_row(), Some(Item::Presets));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliders_stop_at_the_entrys_step() {
|
||||
let mut c = Configs::default();
|
||||
let json = serde_json::json!({"default_value": 4.0, "min": 0.0, "max": 10.0, "step": 2.0});
|
||||
c.add_entry("p:clouds".into(), &json);
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(c);
|
||||
let row = &shell.screen.sheets(&s)[0].groups[1].rows[0];
|
||||
let stops = Value::Slider {
|
||||
position: 0.4,
|
||||
steps: Some(5),
|
||||
text: "4".into(),
|
||||
};
|
||||
assert_eq!(row.value, stops);
|
||||
// A press a little past the middle of the track lands on the step nearest it.
|
||||
let at = shell.point_at(&s, SIZE, Item::Entry(0));
|
||||
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
|
||||
shell.click(
|
||||
&mut s,
|
||||
(track + width * 0.55, at.1),
|
||||
SIZE,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
shell.release();
|
||||
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(6.0)));
|
||||
// Held by the press, Right goes one step on and Left one back.
|
||||
shell.set_focus(Some(Target::Row(Item::Entry(0))));
|
||||
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(8.0)));
|
||||
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
|
||||
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
|
||||
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(4.0)));
|
||||
}
|
||||
|
||||
/// A language with the pack's own strings: a name, a section and a tooltip.
|
||||
struct PackLang;
|
||||
impl lang::ResourceSource for PackLang {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/minecraft/lang/en_us.json").then(|| {
|
||||
let strings = serde_json::json!({
|
||||
"config.minecraft.haze": "Haze",
|
||||
"config.minecraft.haze.tooltip": "Thickens the distant air.",
|
||||
"config.minecraft.section.sky": "Sky and fog",
|
||||
});
|
||||
serde_json::to_vec(&strings).unwrap()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn haze() -> Configs {
|
||||
let mut c = configs();
|
||||
let json =
|
||||
serde_json::json!({"default_value": false, "section": "sky", "performance_impact": "high"});
|
||||
c.add_entry("minecraft:haze".into(), &json);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_finds_an_entry_by_its_tooltip_and_the_strip_has_its_impact() {
|
||||
let language = lang::Language::load(&[&PackLang], "en_us");
|
||||
lang::scoped(language, || {
|
||||
let s = Settings::default();
|
||||
let mut shell = shell_of(haze());
|
||||
let (page, haze) = entry(&shell, "minecraft:haze");
|
||||
shell.set_search("distant air");
|
||||
assert_eq!(shell.listed(&s, SIZE), [(page, haze)]);
|
||||
let sheets = shell.screen.sheets(&s);
|
||||
assert_eq!(sheets[usize::from(page)].name, "Sky and fog");
|
||||
let row = sheets.iter().flat_map(|p| &p.groups).flat_map(|g| &g.rows);
|
||||
let row = row.into_iter().find(|r| r.id == haze).unwrap();
|
||||
assert_eq!(row.name, "Haze");
|
||||
assert_eq!(
|
||||
row.about,
|
||||
"Thickens the distant air. Performance impact: high"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_reaches_every_row_of_every_page() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(haze());
|
||||
let pages = shell.screen.sheets(&s).len() as u16;
|
||||
let mut seen = Vec::new();
|
||||
for page in 0..pages {
|
||||
shell.show(page);
|
||||
shell.set_focus(None);
|
||||
for _ in 0..8 {
|
||||
shell.key(&mut s, K::Tab, None, false, SIZE);
|
||||
if let Some(row) = shell.focused_row()
|
||||
&& !seen.contains(&row)
|
||||
{
|
||||
seen.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset all is greyed at the defaults; every entry is reached.
|
||||
let mut entries: Vec<Item> = (0..shell.screen.ids.len() as u16)
|
||||
.map(Item::Entry)
|
||||
.collect();
|
||||
seen.sort_by_key(|item| format!("{item:?}"));
|
||||
entries.sort_by_key(|item| format!("{item:?}"));
|
||||
assert_eq!(seen, entries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_puts_the_entries_back_and_then_leaving_sends_nothing() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(configs());
|
||||
let (page, birds) = entry(&shell, "p:birds");
|
||||
shell.show(page);
|
||||
press(&mut shell, &mut s, birds);
|
||||
assert!(shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
|
||||
assert!(shell.screen.finished().is_some());
|
||||
let undo = shell
|
||||
.foot_at(&s, SIZE)
|
||||
.into_iter()
|
||||
.find(|f| f.0 == Target::Undo);
|
||||
let [x, y, w, h] = undo.unwrap().1;
|
||||
shell.click(
|
||||
&mut s,
|
||||
(x + w / 2.0, y + h / 2.0),
|
||||
SIZE,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(true)));
|
||||
assert!(!shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
|
||||
assert_eq!(shell.screen.finished(), None);
|
||||
// Stepped there and back by hand, it sends nothing either.
|
||||
press(&mut shell, &mut s, birds);
|
||||
press(&mut shell, &mut s, birds);
|
||||
assert_eq!(shell.screen.finished(), None);
|
||||
press(&mut shell, &mut s, birds);
|
||||
let Some(Event::PackOptions { entries, chosen }) = shell.screen.finished() else {
|
||||
panic!("a change is sent");
|
||||
};
|
||||
assert_eq!(entries.len(), 3);
|
||||
assert_eq!(chosen.get("p:birds"), Some(&ConfigValue::Bool(false)));
|
||||
}
|
||||
@@ -468,17 +468,20 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
|
||||
/// serde_json rejects it.
|
||||
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
first_json(bytes)
|
||||
first_json(bytes).ok()
|
||||
}
|
||||
|
||||
/// Reads the first JSON value from resource metadata, matching Gson's
|
||||
/// `JsonReader` (which ignores a trailing signature or comment). Resource
|
||||
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
|
||||
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
|
||||
/// The first JSON value of a `.mcmeta` file, as `ResourceMetadata.fromJsonStream` reads it:
|
||||
/// Gson's `TypeAdapter.read` stops after the value, so text after it (packs leave `//` notes
|
||||
/// there) is never read, and a UTF-8 byte order mark is skipped.
|
||||
pub(crate) fn first_json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> serde_json::Result<T> {
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
match serde_json::Deserializer::from_slice(bytes)
|
||||
.into_iter()
|
||||
.next()?
|
||||
.ok()
|
||||
.next()
|
||||
{
|
||||
Some(value) => value,
|
||||
None => Err(serde::de::Error::custom("empty metadata")),
|
||||
}
|
||||
}
|
||||
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
|
||||
/// in the pack's base, not its overlays) at their default values.
|
||||
@@ -827,13 +830,6 @@ fn build_overlay_uncached(
|
||||
}
|
||||
// Vanilla sits below every pack, so filters hide its files too.
|
||||
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
.keys()
|
||||
.flat_map(|f| {
|
||||
let parts: Vec<&str> = f.split('/').collect();
|
||||
(1..parts.len()).map(move |n| parts[..n].join("/"))
|
||||
})
|
||||
.collect();
|
||||
let zips: Vec<Option<Zip>> = packs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
@@ -882,6 +878,21 @@ fn build_overlay_uncached(
|
||||
merged.insert(name.clone(), bytes);
|
||||
}
|
||||
}
|
||||
add_sprite_renames(
|
||||
vanilla,
|
||||
&merged,
|
||||
&filters,
|
||||
&mut files,
|
||||
&mut hidden,
|
||||
dependencies,
|
||||
);
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
.keys()
|
||||
.flat_map(|f| {
|
||||
let parts: Vec<&str> = f.split('/').collect();
|
||||
(1..parts.len()).map(move |n| parts[..n].join("/"))
|
||||
})
|
||||
.collect();
|
||||
/// A zip entry to write: its pack, its name in the zip and where it goes.
|
||||
type Extract<'a> = (usize, &'a str, PathBuf);
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1032,6 +1043,123 @@ fn build_overlay_uncached(
|
||||
}
|
||||
extracted
|
||||
}
|
||||
/// `SingleFile` with a `sprite`: the atlas holds the resource's pixels, and the resource's
|
||||
/// `.mcmeta`, under the sprite's id. Every other sprite of the blocks and items atlases is its
|
||||
/// texture's own id, and the loaders read a model's sprite from `textures/<sprite>.png`, so the
|
||||
/// overlay gives a renamed sprite that file. ItemsAdder and Nexo packs name their models'
|
||||
/// textures only through these renames (#14485).
|
||||
///
|
||||
/// A sprite gets its resource's file only when the whole source list, in order, ends on the
|
||||
/// rename (`SpriteSourceList.list`: a later source of the same id replaces it), when the other
|
||||
/// atlas, which reads the same file, lists it from that resource too or not at all, and when it
|
||||
/// has no texture of its own. A texture that is there may be read as itself (another source's
|
||||
/// resource, an unstitched or paletted sprite's input), so no alias ever replaces one; such a
|
||||
/// sprite draws its own texture, as before aliases.
|
||||
fn add_sprite_renames(
|
||||
vanilla: &Path,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
files: &mut BTreeMap<String, (usize, Provided)>,
|
||||
hidden: &mut BTreeSet<String>,
|
||||
dependencies: &mut cache::Dependencies,
|
||||
) {
|
||||
use crate::atlas_sources::{
|
||||
SpriteOrigin, SpriteSource, decode, list, list_textures, texture_path,
|
||||
};
|
||||
let png = |id: &str| {
|
||||
texture_path(Path::new(""), id, "textures")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
};
|
||||
let atlases: Vec<_> = ["blocks", "items"]
|
||||
.into_iter()
|
||||
.filter_map(|atlas| merged.get(&format!("assets/minecraft/atlases/{atlas}.json")))
|
||||
.filter_map(|bytes| decode(std::str::from_utf8(bytes).ok()?))
|
||||
.collect();
|
||||
let renamed = |source: &SpriteSource| matches!(source, SpriteSource::Single { resource, sprite: Some(sprite) } if sprite != resource);
|
||||
if !atlases.iter().flatten().any(renamed) {
|
||||
return;
|
||||
}
|
||||
// The textures a source can list: the packs' and vanilla's the packs leave visible.
|
||||
let mut textures: Vec<String> = list_textures(vanilla)
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
let name = png(id);
|
||||
!hidden.contains(&name) && !filters.iter().any(|f| f.blocks(&name))
|
||||
})
|
||||
.chain(files.keys().filter_map(|name| {
|
||||
let (namespace, rest) = name.strip_prefix("assets/")?.split_once('/')?;
|
||||
let path = rest.strip_prefix("textures/")?.strip_suffix(".png")?;
|
||||
Some(format!("{namespace}:{path}"))
|
||||
}))
|
||||
.collect();
|
||||
textures.sort();
|
||||
textures.dedup();
|
||||
// Each sprite's resource, `None` where an atlas lists it any other way.
|
||||
let mut renames: BTreeMap<String, Option<String>> = BTreeMap::new();
|
||||
for sources in &atlases {
|
||||
for (sprite, origin) in list(sources, &textures) {
|
||||
let from = match origin {
|
||||
SpriteOrigin::File(resource) if resource != sprite => Some(resource),
|
||||
_ => None,
|
||||
};
|
||||
renames
|
||||
.entry(sprite)
|
||||
.and_modify(|seen| {
|
||||
if *seen != from {
|
||||
*seen = None;
|
||||
}
|
||||
})
|
||||
.or_insert(from);
|
||||
}
|
||||
}
|
||||
let renames = renames
|
||||
.into_iter()
|
||||
.filter(|(sprite, _)| textures.binary_search(sprite).is_err())
|
||||
.filter_map(|(sprite, resource)| Some((png(&resource?), png(&sprite))));
|
||||
// Each resource is the file the packs give, never another rename's sprite.
|
||||
let mut found = Vec::new();
|
||||
for (from, to) in renames {
|
||||
// `texture_path` names an id that would leave the root as a file that is never there.
|
||||
if !stays_inside(&from) || !stays_inside(&to) {
|
||||
continue;
|
||||
}
|
||||
let from_meta = format!("{from}.mcmeta");
|
||||
// A pack's texture with its own `.mcmeta`, or vanilla's below every pack.
|
||||
let (file, meta) = match files.get(&from) {
|
||||
Some(file) => (file.clone(), files.get(&from_meta).cloned()),
|
||||
None => {
|
||||
let (path, meta_path) = (vanilla.join(&from), vanilla.join(&from_meta));
|
||||
dependencies.file(&path);
|
||||
dependencies.file(&meta_path);
|
||||
if !path.is_file() || filters.iter().any(|f| f.blocks(&from)) {
|
||||
continue;
|
||||
}
|
||||
// `createStackMetadataFinder`: a pack's filter hides vanilla's `.mcmeta` too.
|
||||
let meta = files.get(&from_meta).cloned().or_else(|| {
|
||||
(meta_path.is_file() && !filters.iter().any(|f| f.blocks(&from_meta)))
|
||||
.then(|| (0, (Some(meta_path), from_meta.clone())))
|
||||
});
|
||||
((0, (Some(path), from.clone())), meta)
|
||||
}
|
||||
};
|
||||
found.push((to, file, meta));
|
||||
}
|
||||
for (to, file, meta) in found {
|
||||
let to_meta = format!("{to}.mcmeta");
|
||||
files.insert(to, file);
|
||||
match meta {
|
||||
Some(meta) => {
|
||||
hidden.remove(&to_meta);
|
||||
files.insert(to_meta, meta);
|
||||
}
|
||||
None => {
|
||||
files.remove(&to_meta);
|
||||
hidden.insert(to_meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
|
||||
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
|
||||
/// for a small pack of text, and never past 8 GiB.
|
||||
@@ -1906,6 +2034,94 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn a_renamed_atlas_sprite_reads_its_resource() {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-rename-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let write = |path: PathBuf, bytes: &[u8]| {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
};
|
||||
let vanilla = base.join("vanilla");
|
||||
let textures = vanilla.join("assets/minecraft/textures");
|
||||
let animation = b"{\"animation\":{}}";
|
||||
write(textures.join("block/sea_lantern.png"), b"lantern");
|
||||
write(textures.join("block/sea_lantern.png.mcmeta"), animation);
|
||||
write(textures.join("item/stick.png"), b"vanilla stick");
|
||||
write(textures.join("item/stick.png.mcmeta"), animation);
|
||||
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
|
||||
let pack = base.join("packs/IA");
|
||||
write(pack.join("pack.mcmeta"), meta);
|
||||
write(pack.join("assets/iageneric/textures/item/box.png"), b"box");
|
||||
// An ItemsAdder pack's items atlas: its models name only the `ia:` sprites.
|
||||
write(
|
||||
pack.join("assets/minecraft/atlases/items.json"),
|
||||
br#"{"sources":[
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:0"},
|
||||
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:1199"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"minecraft:item/stick"},
|
||||
{"type":"single","resource":"minecraft:item/stick","sprite":"ia:3"},
|
||||
{"type":"single","resource":"iageneric:item/gone","sprite":"ia:2"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:../../../escape"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:7"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:8"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:9"}
|
||||
]}"#,
|
||||
);
|
||||
// A pack above it: later sources of three of those sprites, a blocks atlas that
|
||||
// disagrees about another, and a filter hiding vanilla's animation of the stick.
|
||||
let higher = base.join("packs/Top");
|
||||
write(
|
||||
higher.join("pack.mcmeta"),
|
||||
br#"{"pack":{"description":"d","pack_format":97},
|
||||
"filter":{"block":[{"path":"textures/item/stick\\.png\\.mcmeta"}]}}"#,
|
||||
);
|
||||
write(
|
||||
higher.join("assets/minecraft/atlases/items.json"),
|
||||
br#"{"sources":[
|
||||
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:8"},
|
||||
{"type":"filter","pattern":{"namespace":"ia","path":"7"}}
|
||||
]}"#,
|
||||
);
|
||||
write(
|
||||
higher.join("assets/minecraft/atlases/blocks.json"),
|
||||
br#"{"sources":[
|
||||
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:9"}
|
||||
]}"#,
|
||||
);
|
||||
let packs = discover(&vanilla, &base.join("packs"));
|
||||
let out = base.join("overlay");
|
||||
build_overlay(&vanilla, &packs, &out).unwrap();
|
||||
let read = |rel: &str| fs::read(out.join(rel)).ok();
|
||||
let texture = |id: &str| read(&format!("assets/ia/textures/{id}.png"));
|
||||
assert_eq!(texture("0").as_deref(), Some(&b"box"[..]));
|
||||
assert_eq!(read("assets/ia/textures/0.png.mcmeta"), None);
|
||||
// A vanilla resource comes with its animation.
|
||||
assert_eq!(texture("1199").as_deref(), Some(&b"lantern"[..]));
|
||||
assert_eq!(
|
||||
read("assets/ia/textures/1199.png.mcmeta").as_deref(),
|
||||
Some(&animation[..])
|
||||
);
|
||||
// A texture that is there stays itself: other sources may read it (here as `ia:3`'s
|
||||
// resource; an unstitched or paletted sprite's input alike).
|
||||
assert_eq!(
|
||||
read("assets/minecraft/textures/item/stick.png").as_deref(),
|
||||
Some(&b"vanilla stick"[..])
|
||||
);
|
||||
// ...with its `.mcmeta` only where no filter hides it.
|
||||
assert_eq!(texture("3").as_deref(), Some(&b"vanilla stick"[..]));
|
||||
assert_eq!(read("assets/ia/textures/3.png.mcmeta"), None);
|
||||
// A missing resource adds nothing, and no rename leaves the overlay.
|
||||
assert_eq!(texture("2"), None);
|
||||
assert!(!out.join("escape.png").exists());
|
||||
// The last source of a sprite wins: a filter drops it, a later rename replaces it.
|
||||
assert_eq!(texture("7"), None);
|
||||
assert_eq!(texture("8").as_deref(), Some(&b"lantern"[..]));
|
||||
// The two atlases read one file, and disagree about `ia:9`: it gets neither.
|
||||
assert_eq!(texture("9"), None);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
|
||||
|
||||
@@ -13,7 +13,8 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
// Version 7 stacks blockstate files (every pack's definition, bottom first).
|
||||
const VERSION: u32 = 8;
|
||||
// Version 8 left an atlas `single` source's renamed sprite without its texture file (#14485).
|
||||
const VERSION: u32 = 9;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
|
||||
@@ -1402,100 +1402,8 @@ impl ParticleLightCache {
|
||||
}
|
||||
}
|
||||
|
||||
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SectionFrustumResult {
|
||||
Outside,
|
||||
Intersects,
|
||||
Inside,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CachedSectionFrustum {
|
||||
section: [i32; 3],
|
||||
radius_quarters: i32,
|
||||
result: SectionFrustumResult,
|
||||
}
|
||||
|
||||
struct ParticleSectionFrustumCache {
|
||||
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
|
||||
impl Default for ParticleSectionFrustumCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParticleSectionFrustumCache {
|
||||
fn result(
|
||||
&mut self,
|
||||
world: [f64; 3],
|
||||
camera: DVec3,
|
||||
radius: f64,
|
||||
frustum: &crate::gfx::Frustum,
|
||||
) -> SectionFrustumResult {
|
||||
let Some(section) = world_section(world) else {
|
||||
return SectionFrustumResult::Intersects;
|
||||
};
|
||||
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
|
||||
return SectionFrustumResult::Intersects;
|
||||
}
|
||||
let radius_quarters = (radius * 4.0).ceil() as i32;
|
||||
let hash = (section[0] as u32).wrapping_mul(73_856_093)
|
||||
^ (section[1] as u32).wrapping_mul(19_349_663)
|
||||
^ (section[2] as u32).wrapping_mul(83_492_791)
|
||||
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
|
||||
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
|
||||
if let Some(entry) = self.entries[slot]
|
||||
&& entry.section == section
|
||||
&& entry.radius_quarters == radius_quarters
|
||||
{
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
let pad = f64::from(radius_quarters) * 0.25;
|
||||
let camera = camera.to_array();
|
||||
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
|
||||
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
|
||||
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
let min = FVec3::from_array(min.map(|v| v as f32));
|
||||
let max = FVec3::from_array(max.map(|v| v as f32));
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
SectionFrustumResult::Intersects
|
||||
} else if frustum.contains(min, max) {
|
||||
SectionFrustumResult::Inside
|
||||
} else if frustum.intersects(min, max) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
SectionFrustumResult::Outside
|
||||
}
|
||||
};
|
||||
self.entries[slot] = Some(CachedSectionFrustum {
|
||||
section,
|
||||
radius_quarters,
|
||||
result,
|
||||
});
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
|
||||
let mut block = [0; 3];
|
||||
for axis in 0..3 {
|
||||
let value = position[axis].floor();
|
||||
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
|
||||
return None;
|
||||
}
|
||||
block[axis] = value as i32;
|
||||
}
|
||||
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
|
||||
}
|
||||
mod view_cull;
|
||||
use view_cull::{PARTICLE_FRUSTUM_OFFSET, ParticleSectionFrustumCache, SectionFrustumResult};
|
||||
|
||||
fn below_half_pixel(radius: f64, distance_squared: f64, pixel_focal_length: f32) -> bool {
|
||||
let projected_limit = 4.0 * radius * f64::from(pixel_focal_length);
|
||||
@@ -4703,16 +4611,7 @@ impl ParticleEngine {
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.render_inner::<false>(
|
||||
camera,
|
||||
base,
|
||||
partial,
|
||||
0.0,
|
||||
None,
|
||||
&mut |_, _| false,
|
||||
light_at,
|
||||
out,
|
||||
);
|
||||
self.render_in_view(camera, base, partial, None, light_at, out);
|
||||
}
|
||||
|
||||
/// Render particles after rejecting view-frustum misses and any particles
|
||||
@@ -4730,7 +4629,7 @@ impl ParticleEngine {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !culling_enabled {
|
||||
self.render(camera, base, partial, light_at, out);
|
||||
self.render_in_view(camera, base, partial, frustum, light_at, out);
|
||||
return;
|
||||
}
|
||||
self.render_inner::<true>(
|
||||
@@ -4761,10 +4660,16 @@ impl ParticleEngine {
|
||||
additive.clear();
|
||||
let mut section_cache =
|
||||
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
|
||||
let view = frustum.map(|f| f.pulled_back(PARTICLE_FRUSTUM_OFFSET));
|
||||
for p in &self.particles {
|
||||
if p.no_render {
|
||||
continue;
|
||||
}
|
||||
if let Some(view) = &view
|
||||
&& !view_cull::in_view(view, p, camera)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// SparkParticle.extract: twinkling sparks blink after a third of their life.
|
||||
if let Behaviour::Spark { twinkle: true, .. } = p.behaviour
|
||||
&& p.age >= p.lifetime / 3
|
||||
@@ -5199,6 +5104,115 @@ mod tests {
|
||||
assert_eq!(attributes(&disabled), attributes(®ular));
|
||||
}
|
||||
|
||||
/// An engine whose smoke particles stand at `positions`, camera-relative (the camera is at
|
||||
/// the origin looking down -z, like `Mat4::IDENTITY` as a view).
|
||||
fn smoke_at(positions: &[[f64; 3]]) -> ParticleEngine {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let sprite = "test:smoke".to_owned();
|
||||
engine.atlas.insert(
|
||||
sprite.clone(),
|
||||
(
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
let mut random = LegacyRandom::new(7);
|
||||
for &at in positions {
|
||||
let mut particle = Particle::moving(&mut random, ParticleType::Smoke, at, [0.0; 3]);
|
||||
particle.sprite = Sprite::Named(sprite.clone());
|
||||
engine.particles.push(particle);
|
||||
}
|
||||
engine
|
||||
}
|
||||
|
||||
fn quad_vertices(engine: &ParticleEngine, view: Option<&crate::gfx::Frustum>) -> usize {
|
||||
let mut out = Vec::new();
|
||||
engine.render_in_view(DVec3::ZERO, Quat::IDENTITY, 0.5, view, &|_| 0xf0, &mut out);
|
||||
out.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particles_outside_the_pulled_back_frustum_make_no_quads() {
|
||||
use crate::gfx::{Frustum, world_projection};
|
||||
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
|
||||
let kept = [
|
||||
[0.0, 0.0, -5.0], // straight ahead
|
||||
[0.0, 0.0, 2.5], // behind the camera, inside the 3 blocks of offset
|
||||
[0.0, 0.0, -300.0], // far, inside the far plane
|
||||
];
|
||||
let dropped = [
|
||||
[0.0, 0.0, 10.0], // well behind the camera
|
||||
[0.0, 0.0, 3.5], // just past the offset
|
||||
[100.0, 0.0, -5.0], // far to the side
|
||||
[0.0, 60.0, -5.0], // far above
|
||||
[0.0, 0.0, -600.0], // beyond the far plane
|
||||
];
|
||||
let all: Vec<_> = kept.iter().chain(&dropped).copied().collect();
|
||||
let engine = smoke_at(&all);
|
||||
let only_kept = quad_vertices(&smoke_at(&kept), None);
|
||||
assert!(only_kept > 0);
|
||||
assert_eq!(quad_vertices(&engine, Some(&view)), only_kept);
|
||||
// Without a view every particle is drawn, as before.
|
||||
assert_eq!(
|
||||
quad_vertices(&engine, None),
|
||||
only_kept * all.len() / kept.len()
|
||||
);
|
||||
// `render` is the same as no view.
|
||||
let mut regular = Vec::new();
|
||||
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &|_| 0xf0, &mut regular);
|
||||
assert_eq!(regular.len(), quad_vertices(&engine, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_culled_path_applies_the_same_frustum_test() {
|
||||
use crate::gfx::{Frustum, world_projection};
|
||||
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
|
||||
let engine = smoke_at(&[[0.0, 0.0, -5.0], [0.0, 0.0, 10.0]]);
|
||||
let mut out = Vec::new();
|
||||
// The disabled setting still keeps vanilla's always-on test.
|
||||
engine.render_culled(
|
||||
DVec3::ZERO,
|
||||
Quat::IDENTITY,
|
||||
0.5,
|
||||
900.0,
|
||||
false,
|
||||
Some(&view),
|
||||
|_, _| panic!("disabled culling must not query particle visibility"),
|
||||
&|_| 0xf0,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
quad_vertices(&smoke_at(&[[0.0, 0.0, -5.0]]), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_particle_is_tested_at_its_position_this_tick_not_an_interpolated_one() {
|
||||
use crate::gfx::{Frustum, world_projection};
|
||||
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
|
||||
// The frame is drawn half way (0.5) between `previous` and `position`.
|
||||
// Ahead now, far behind a tick ago (half way: 12.5 behind, outside): kept.
|
||||
let mut coming = smoke_at(&[[0.0, 0.0, -5.0]]);
|
||||
coming.particles[0].previous = [0.0, 0.0, 30.0];
|
||||
assert!(quad_vertices(&coming, None) > 0);
|
||||
assert_eq!(
|
||||
quad_vertices(&coming, Some(&view)),
|
||||
quad_vertices(&coming, None)
|
||||
);
|
||||
// Behind now, ahead a tick ago (half way: 5 ahead, inside): dropped.
|
||||
let mut going = smoke_at(&[[0.0, 0.0, 10.0]]);
|
||||
going.particles[0].previous = [0.0, 0.0, -20.0];
|
||||
assert_eq!(quad_vertices(&going, Some(&view)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quads_face_the_rotation_they_are_given() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! The camera's view frustum for particles: vanilla's always-on point test, and the section
|
||||
//! cache behind the opt-in Particle Culling setting.
|
||||
|
||||
use super::{Particle, ParticleEngine, Vertex};
|
||||
use glam::{DVec3, Quat, Vec3 as FVec3};
|
||||
use std::array;
|
||||
|
||||
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
|
||||
/// How far back, in blocks, the camera's frustum is pulled for the particle test (vanilla's
|
||||
/// `Frustum.offset` in `ParticleEngine.extract`).
|
||||
pub(super) const PARTICLE_FRUSTUM_OFFSET: f32 = 3.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SectionFrustumResult {
|
||||
Outside,
|
||||
Intersects,
|
||||
Inside,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CachedSectionFrustum {
|
||||
section: [i32; 3],
|
||||
radius_quarters: i32,
|
||||
result: SectionFrustumResult,
|
||||
}
|
||||
|
||||
pub(super) struct ParticleSectionFrustumCache {
|
||||
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
|
||||
impl Default for ParticleSectionFrustumCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParticleSectionFrustumCache {
|
||||
pub(super) fn result(
|
||||
&mut self,
|
||||
world: [f64; 3],
|
||||
camera: DVec3,
|
||||
radius: f64,
|
||||
frustum: &crate::gfx::Frustum,
|
||||
) -> SectionFrustumResult {
|
||||
let Some(section) = world_section(world) else {
|
||||
return SectionFrustumResult::Intersects;
|
||||
};
|
||||
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
|
||||
return SectionFrustumResult::Intersects;
|
||||
}
|
||||
let radius_quarters = (radius * 4.0).ceil() as i32;
|
||||
let hash = (section[0] as u32).wrapping_mul(73_856_093)
|
||||
^ (section[1] as u32).wrapping_mul(19_349_663)
|
||||
^ (section[2] as u32).wrapping_mul(83_492_791)
|
||||
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
|
||||
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
|
||||
if let Some(entry) = self.entries[slot]
|
||||
&& entry.section == section
|
||||
&& entry.radius_quarters == radius_quarters
|
||||
{
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
let pad = f64::from(radius_quarters) * 0.25;
|
||||
let camera = camera.to_array();
|
||||
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
|
||||
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
|
||||
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
let min = FVec3::from_array(min.map(|v| v as f32));
|
||||
let max = FVec3::from_array(max.map(|v| v as f32));
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
SectionFrustumResult::Intersects
|
||||
} else if frustum.contains(min, max) {
|
||||
SectionFrustumResult::Inside
|
||||
} else if frustum.intersects(min, max) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
SectionFrustumResult::Outside
|
||||
}
|
||||
};
|
||||
self.entries[slot] = Some(CachedSectionFrustum {
|
||||
section,
|
||||
radius_quarters,
|
||||
result,
|
||||
});
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
|
||||
let mut block = [0; 3];
|
||||
for axis in 0..3 {
|
||||
let value = position[axis].floor();
|
||||
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
|
||||
return None;
|
||||
}
|
||||
block[axis] = value as i32;
|
||||
}
|
||||
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
|
||||
}
|
||||
|
||||
/// Whether `QuadParticleGroup`'s test keeps `particle`: its position this tick (camera-relative, not
|
||||
/// interpolated) is on or inside `view`, the camera's frustum pulled back [`PARTICLE_FRUSTUM_OFFSET`] blocks.
|
||||
pub(super) fn in_view(view: &crate::gfx::Frustum, particle: &Particle, camera: DVec3) -> bool {
|
||||
let at = DVec3::from_array(particle.position) - camera;
|
||||
view.contains_point(at.as_vec3())
|
||||
}
|
||||
|
||||
impl ParticleEngine {
|
||||
/// [`Self::render`], keeping only the particles inside `view`, the camera's frustum pulled
|
||||
/// back [`PARTICLE_FRUSTUM_OFFSET`] blocks (vanilla's `ParticleEngine.extract` and
|
||||
/// `QuadParticleGroup`): the test runs whatever the Particle Culling setting says. With
|
||||
/// `None`, every particle is drawn.
|
||||
pub fn render_in_view(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
view: Option<&crate::gfx::Frustum>,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.render_inner::<false>(
|
||||
camera,
|
||||
base,
|
||||
partial,
|
||||
0.0,
|
||||
view,
|
||||
&mut |_, _| false,
|
||||
light_at,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -222,11 +222,6 @@ impl<P: Copy, S: Copy> Lines<P, S> {
|
||||
}
|
||||
self.y += style::GROUP_GAP;
|
||||
}
|
||||
/// A group of rows under its page's own name (`list`'s `page_name`), for pages named at run
|
||||
/// time, such as a resource pack's config entries (`pack_options_ui`).
|
||||
pub(crate) fn titled(&mut self, page: P, rows: Vec<S>) {
|
||||
self.group(page, "", rows);
|
||||
}
|
||||
/// The lines and the content's height.
|
||||
pub(crate) fn finish(self) -> (Vec<Line<P, S>>, f32) {
|
||||
(self.lines, (self.y - style::GROUP_GAP).max(0.0))
|
||||
@@ -352,8 +347,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
|
||||
}
|
||||
|
||||
/// The list, clipped to its view: each group's panel, then its names and rows on it. `row`
|
||||
/// draws a setting at its `y`; `page_name` names a page, and a group without a name of its own
|
||||
/// (`Lines::titled`), in the player's language.
|
||||
/// draws a setting at its `y`; `page_name` names a page, in the player's language.
|
||||
pub(crate) fn list<P: Copy, S: Copy>(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
@@ -386,7 +380,6 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
}
|
||||
let Some(setting) = line.setting else {
|
||||
let (name, color, dy) = match line.group {
|
||||
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
|
||||
None => (page_name(line.page), style::accent(), 4.0),
|
||||
};
|
||||
@@ -952,22 +945,10 @@ pub(crate) fn dock_note(
|
||||
}
|
||||
}
|
||||
|
||||
/// Done or Undo at the foot of the page list.
|
||||
/// Done, Undo or Apply at the foot of the page list, registered for a controller. Its label
|
||||
/// leaves `room` at its left for a controller's glyph: a button that declared its job gets one,
|
||||
/// as vanilla's do (`ui::action_button`), which a half-width Undo or Apply needs.
|
||||
pub(crate) fn foot_button(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
rect: [f32; 4],
|
||||
label: &Text,
|
||||
cursor: (f32, f32),
|
||||
flags: (bool, bool),
|
||||
) {
|
||||
foot_button_with_room(batch, atlas, rect, label, cursor, flags, 0.0);
|
||||
}
|
||||
|
||||
/// A foot button whose label leaves `room` at its left for a controller's glyph: a button that
|
||||
/// declared its job gets one, as vanilla's do (`ui::action_button`), which a half-width Undo or
|
||||
/// Apply needs.
|
||||
fn foot_button_with_room(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
rect: [f32; 4],
|
||||
@@ -2414,7 +2395,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
foot_button_with_room(batch, atlas, rect, &label, cursor, flags, room);
|
||||
foot_button(batch, atlas, rect, &label, cursor, flags, room);
|
||||
}
|
||||
}
|
||||
fn click(
|
||||
|
||||
@@ -478,9 +478,9 @@ impl Sprites {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
Ok(text) => Some(crate::packs::first_json::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
>(text.as_bytes())?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
@@ -589,9 +589,9 @@ impl Sprites {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
Ok(text) => Some(crate::packs::first_json::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
>(text.as_bytes())?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
@@ -1438,7 +1438,7 @@ impl Streaming {
|
||||
let entities = std::mem::take(&mut chunk.entities);
|
||||
world.entities_pending.insert((chunk.x, chunk.z));
|
||||
if !entities.is_empty() {
|
||||
world.loaded_entities.push(((chunk.x, chunk.z), entities));
|
||||
world.queue_entity_nbt((chunk.x, chunk.z), entities);
|
||||
}
|
||||
self.generated_total += 1;
|
||||
// A chunk saved with its light needs no light job; its unlit
|
||||
|
||||
@@ -104,7 +104,7 @@ pub fn read_metadata(root: &Path) -> UiMetadata {
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
let Some(nine_slice) = crate::packs::first_json(text.as_bytes())
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
@@ -2025,6 +2025,28 @@ impl EditBox {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notes_after_a_sprites_metadata_keep_its_nine_slice() {
|
||||
// Gson stops reading after the first value, so vanilla keeps this nine slice
|
||||
// (CozyUI+'s highlighted button, #3046).
|
||||
let root = std::env::temp_dir().join(format!("horizon-ui-meta-{}", std::process::id()));
|
||||
let widget = root.join("assets/minecraft/textures/gui/sprites/widget");
|
||||
std::fs::create_dir_all(&widget).unwrap();
|
||||
std::fs::write(
|
||||
widget.join("button_highlighted.png.mcmeta"),
|
||||
"{\"gui\": {\"scaling\": {\"type\": \"nine_slice\", \"width\": 80, \"height\": 20,\n\
|
||||
\"border\": {\"left\": 34, \"top\": 8, \"right\": 34, \"bottom\": 8}}}}\n\n// 0 10\n// 1 8\n",
|
||||
)
|
||||
.unwrap();
|
||||
let metadata = read_metadata(&root);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
let slice = metadata.0["gui/sprites/widget/button_highlighted"];
|
||||
assert_eq!(
|
||||
(slice.width, slice.height, slice.border),
|
||||
(80, 20, [34, 8, 34, 8])
|
||||
);
|
||||
}
|
||||
|
||||
/// The quads' destination rectangles and their UVs.
|
||||
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
|
||||
batch
|
||||
|
||||
@@ -9,6 +9,9 @@ use horizon_core::random::{LegacyRandom, LevelRandom};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod pending_entity;
|
||||
pub use pending_entity::{EntityLoadReason, PendingEntity};
|
||||
|
||||
mod modified;
|
||||
pub use modified::ModifiedChunks;
|
||||
|
||||
@@ -81,7 +84,7 @@ pub struct World {
|
||||
/// incrementally by streaming.
|
||||
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
|
||||
/// Saved entities of chunks that just loaded, spawned by the game.
|
||||
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
|
||||
pub loaded_entities: Vec<((i32, i32), Vec<PendingEntity>)>,
|
||||
/// Chunks loaded since the level's last pass, their entity sections not
|
||||
/// ready yet (`PersistentEntitySectionManager`'s PENDING), empty ones too:
|
||||
/// the pass ends by readying them (`processPendingLoads`).
|
||||
@@ -215,6 +218,12 @@ impl Weather {
|
||||
}
|
||||
}
|
||||
impl World {
|
||||
/// Queue ordinary NBT; transient transfer state is never decoded from it.
|
||||
pub fn queue_entity_nbt(&mut self, key: (i32, i32), tags: Vec<horizon_world::nbt::Tag>) {
|
||||
self.loaded_entities
|
||||
.push((key, tags.into_iter().map(PendingEntity::from).collect()));
|
||||
}
|
||||
|
||||
pub fn new(air: BlockStateId, seed: i64) -> Self {
|
||||
Self {
|
||||
shape: LevelShape::OVERWORLD,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Pending entity NBT and the runtime context that must not be stored inside it.
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntityLoadReason {
|
||||
Ordinary,
|
||||
TeleportedTnt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PendingEntity {
|
||||
tag: Tag,
|
||||
reason: EntityLoadReason,
|
||||
}
|
||||
|
||||
impl From<Tag> for PendingEntity {
|
||||
fn from(tag: Tag) -> Self {
|
||||
Self {
|
||||
tag,
|
||||
reason: EntityLoadReason::Ordinary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingEntity {
|
||||
/// Only an in-memory TNT transfer supplies this context, never saved NBT.
|
||||
pub fn after_tnt_portal(tag: Tag) -> Self {
|
||||
Self {
|
||||
tag,
|
||||
reason: EntityLoadReason::TeleportedTnt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_parts(self) -> (Tag, EntityLoadReason) {
|
||||
(self.tag, self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ordinary_nbt_cannot_request_transient_portal_state() {
|
||||
for marker in [0, 1] {
|
||||
let tag = Tag::compound([("horizon:tnt_used_portal", Tag::Byte(marker))]);
|
||||
let (loaded, reason) = PendingEntity::from(tag.clone()).into_parts();
|
||||
assert_eq!(loaded, tag);
|
||||
assert_eq!(reason, EntityLoadReason::Ordinary);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_swaps_and_appends_keep_each_entitys_context_and_order() {
|
||||
let ordinary = Tag::compound([("id", Tag::String("ordinary".into()))]);
|
||||
let transfer = Tag::compound([("id", Tag::String("transfer".into()))]);
|
||||
let mut batches = vec![(
|
||||
(1, 2),
|
||||
vec![
|
||||
ordinary.clone().into(),
|
||||
PendingEntity::after_tnt_portal(transfer.clone()),
|
||||
],
|
||||
)];
|
||||
let mut waiting = std::mem::take(&mut batches);
|
||||
batches.push(((3, 4), vec![ordinary.clone().into()]));
|
||||
waiting.append(&mut batches);
|
||||
assert!(batches.is_empty());
|
||||
let mut batches = waiting.into_iter();
|
||||
let (key, entities) = batches.next().unwrap();
|
||||
assert_eq!(key, (1, 2));
|
||||
let mut entities = entities.into_iter();
|
||||
assert_eq!(
|
||||
entities.next().unwrap().into_parts(),
|
||||
(ordinary.clone(), EntityLoadReason::Ordinary)
|
||||
);
|
||||
assert_eq!(
|
||||
entities.next().unwrap().into_parts(),
|
||||
(transfer, EntityLoadReason::TeleportedTnt)
|
||||
);
|
||||
assert!(entities.next().is_none());
|
||||
let (key, entities) = batches.next().unwrap();
|
||||
assert_eq!(key, (3, 4));
|
||||
assert_eq!(
|
||||
entities.into_iter().next().unwrap().into_parts(),
|
||||
(ordinary, EntityLoadReason::Ordinary)
|
||||
);
|
||||
assert!(batches.next().is_none());
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,7 @@
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)",
|
||||
"Preset": "Preset",
|
||||
"Horizon Preset": "Horizon Preset",
|
||||
"LOD Distance": "LOD Distance",
|
||||
"Far Detail": "Far Detail",
|
||||
"Cave Culling": "Cave Culling",
|
||||
@@ -707,6 +708,9 @@
|
||||
"Pick a shader pack. Picking the one in use turns shaders off.": "Pick a shader pack. Picking the one in use turns shaders off.",
|
||||
"Changed options apply with Apply, or when you leave.": "Changed options apply with Apply, or when you leave.",
|
||||
"Pack settings": "Pack settings",
|
||||
"Whole pack": "Whole pack",
|
||||
"Reset all": "Reset all",
|
||||
"Every option is at its default already.": "Every option is at its default already.",
|
||||
"Built-in settings": "Built-in settings",
|
||||
"%s settings": "%s settings",
|
||||
"Settings made by the pack's author: they change only this pack.": "Settings made by the pack's author: they change only this pack.",
|
||||
|
||||
@@ -21,5 +21,19 @@ fn main() {
|
||||
if !staged.is_empty() {
|
||||
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
|
||||
}
|
||||
// Whether a native mod can run in a mob's turn (`game/creatures/mob_islands.rs`). The
|
||||
// launcher adds trait packages to the workspace's members.
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
root.join("Cargo.toml").display()
|
||||
);
|
||||
for dir in ["native-api", "native-crates"] {
|
||||
if root.join(dir).is_dir() {
|
||||
println!("cargo:rerun-if-changed={}", root.join(dir).display());
|
||||
}
|
||||
}
|
||||
if let Some(found) = horizon_mod_api_gen::mob_turn(&root) {
|
||||
println!("cargo:rustc-env=HORIZON_MOB_TURN={found}");
|
||||
}
|
||||
windows_exe::resources("horizon-client");
|
||||
}
|
||||
@@ -318,6 +318,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
|
||||
// The star's `setExtendedLifetime` is no part of the loot.
|
||||
drops::Drop::Aged(s, _) => writeln!(out, "{}", line(&s)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1517,10 +1517,8 @@ fn predicate_references(
|
||||
.unwrap_or("");
|
||||
match &*full(kind) {
|
||||
"minecraft:inverted" => predicate_references(&v["term"], w, active),
|
||||
"minecraft:all_of" | "minecraft:any_of" => v["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
"minecraft:all_of" | "minecraft:any_of" => terms(v)
|
||||
.iter()
|
||||
.try_for_each(|v| predicate_references(v, w, active)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
@@ -1529,6 +1527,16 @@ fn predicate_references(
|
||||
}
|
||||
}
|
||||
|
||||
/// `terms` as `HolderSetCodec` reads it: a list, or one holder alone; a `#tag` is not expanded.
|
||||
fn terms(v: &Value) -> &[Value] {
|
||||
match &v["terms"] {
|
||||
Value::Array(list) => list,
|
||||
Value::Null => &[],
|
||||
Value::String(tag) if tag.starts_with('#') => &[],
|
||||
one => std::slice::from_ref(one),
|
||||
}
|
||||
}
|
||||
|
||||
/// The existing condition traversal with a caller-owned leaf context.
|
||||
/// Leaves run synchronously, in order, with normal boolean short-circuiting.
|
||||
pub fn conditions_with(
|
||||
@@ -1547,16 +1555,8 @@ pub fn conditions_with(
|
||||
.unwrap_or("");
|
||||
match &*full(ty) {
|
||||
"minecraft:inverted" => !conditions_with(&v["term"], ctx, test),
|
||||
"minecraft:all_of" => v["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|t| conditions_with(t, ctx, test)),
|
||||
"minecraft:any_of" => v["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|t| conditions_with(t, ctx, test)),
|
||||
"minecraft:all_of" => terms(v).iter().all(|t| conditions_with(t, ctx, test)),
|
||||
"minecraft:any_of" => terms(v).iter().any(|t| conditions_with(t, ctx, test)),
|
||||
_ => test(v, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f6
|
||||
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
|
||||
return true;
|
||||
};
|
||||
// A grouped mob turn: no mod hooks it (`native_client_hooks::Deferred`).
|
||||
if crate::native_client_hooks::deferring() {
|
||||
return true;
|
||||
}
|
||||
let mut cancel = false;
|
||||
let at = [position.x, position.y, position.z];
|
||||
hook(dimension, entity, kind, at, distance, &mut cancel);
|
||||
@@ -87,7 +91,8 @@ fn dispatch(event: Event<'_>, damage: &mut f32) {
|
||||
let contact = match event {
|
||||
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
|
||||
};
|
||||
if contact.block.starts_with("minecraft:") {
|
||||
// A grouped mob turn reaches no mod's hook (`native_client_hooks::Deferred`).
|
||||
if contact.block.starts_with("minecraft:") || crate::native_client_hooks::deferring() {
|
||||
return;
|
||||
}
|
||||
if let Some(dimension) = LEVEL.get()
|
||||
@@ -213,6 +218,15 @@ mod tests {
|
||||
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
|
||||
swept_inside(17, c.kind, at, at, bounds, block);
|
||||
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
|
||||
// A grouped mob turn, on any thread: no mod hears its contacts or tramples.
|
||||
let ((), _) = crate::native_client_hooks::deferring_turn(|| {
|
||||
emit(Event::StepOn(c));
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
let _ = TRAMPLE.set(|_, _, _, _, _, cancel| *cancel = true);
|
||||
assert!(trample_allowed(17, c.kind, c.position, 12.0));
|
||||
});
|
||||
assert_eq!(SEEN.get(), 8, "a grouped turn reaches no contact hook");
|
||||
assert!(!trample_allowed(17, c.kind, c.position, 12.0));
|
||||
});
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
|
||||
|
||||
@@ -123,18 +123,32 @@ pub fn spawn_egg_entity_data<'a>(
|
||||
|
||||
/// `EntityType.appendDefaultStackConfig` for a hanging entity or armor stand an
|
||||
/// item places: `Entity.applyImplicitComponents` stores the item's
|
||||
/// `minecraft:custom_data` as the entity's `data`, then `updateCustomEntityTag`
|
||||
/// `minecraft:custom_name` as the entity's `CustomName` and its
|
||||
/// `minecraft:custom_data` as its `data`, then `updateCustomEntityTag`
|
||||
/// (`TypedEntityData.loadInto`) merges the item's `minecraft:entity_data` over
|
||||
/// the entity's saved tag `own` when its type is `kind`.
|
||||
pub fn stack_configured_entity(stack: &crate::items::ItemStack, kind: &str, own: &Tag) -> Tag {
|
||||
stack_configured_entity_by(stack, kind, own, false)
|
||||
}
|
||||
|
||||
/// [`stack_configured_entity`], `op` saying whether the user may load the
|
||||
/// data of an op-only entity type (`EntityType.onlyOpCanSetNbt`).
|
||||
pub fn stack_configured_entity_by(
|
||||
stack: &crate::items::ItemStack,
|
||||
kind: &str,
|
||||
own: &Tag,
|
||||
op: bool,
|
||||
) -> Tag {
|
||||
let mut tag = own.clone();
|
||||
if let (Tag::Compound(map), Some(custom)) = (
|
||||
&mut tag,
|
||||
stack.data().and_then(|d| d.other("minecraft:custom_data")),
|
||||
) {
|
||||
map.insert("data".into(), custom.clone());
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
if let Some(name) = stack.data().and_then(|d| d.custom_name.clone()) {
|
||||
map.insert("CustomName".into(), name);
|
||||
}
|
||||
if let Some(custom) = stack.data().and_then(|d| d.other("minecraft:custom_data")) {
|
||||
map.insert("data".into(), custom.clone());
|
||||
}
|
||||
}
|
||||
match spawn_egg_entity_data(stack, kind, false) {
|
||||
match spawn_egg_entity_data(stack, kind, op) {
|
||||
Some(data) => crate::data_cmd::merged(&tag, data),
|
||||
None => tag,
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
|
||||
impl ChunkGenerator for DebugTerrain {
|
||||
type Worker = ();
|
||||
fn worker(&self) {}
|
||||
/// DebugLevelSource's proto is already its decorated column. Keep a copy
|
||||
/// for lighting a finished neighbour outside the kept area, matching the
|
||||
/// vanilla FEATURES neighbour path.
|
||||
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
|
||||
proto.downcast_ref::<Chunk>().cloned()
|
||||
}
|
||||
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
|
||||
/// writes the barriers and the grid.
|
||||
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
|
||||
@@ -128,4 +134,20 @@ mod tests {
|
||||
assert_eq!(grid_state(1000, 65, 1), 0);
|
||||
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_view_keeps_a_debug_proto_column() {
|
||||
let terrain = DebugTerrain {
|
||||
shape: LevelShape::OVERWORLD,
|
||||
biome: 0,
|
||||
barrier: 0,
|
||||
states: 0,
|
||||
};
|
||||
let mut chunk = Chunk::empty(-2, 5);
|
||||
chunk.set(0, 0, 0, 42);
|
||||
let proto: crate::streaming::Proto = Box::new(chunk);
|
||||
let view = terrain.light_view(&proto).expect("debug proto light view");
|
||||
assert_eq!((view.x, view.z), (-2, 5));
|
||||
assert_eq!(view.block(0, 0, 0).0, 42);
|
||||
}
|
||||
}
|
||||
@@ -1652,9 +1652,7 @@ pub fn mob_to_nbt(
|
||||
if let Some(saddle) = m.saddle_item() {
|
||||
equipment.insert("saddle".into(), crate::save::item_to_nbt(&saddle));
|
||||
}
|
||||
// A happy ghast wears its harness in the body slot (and a nautilus
|
||||
// its armour, a sulfur cube its swallowed block, a wolf its armor,
|
||||
// with the durability it lost).
|
||||
// Body slot: harness, armour, a cube's block, a wolf's armor; emptied, it leaves the earlier save.
|
||||
if crate::mobs::species::body_slot(m.species())
|
||||
&& let Some(id) = &m.extra.body
|
||||
{
|
||||
@@ -1664,6 +1662,8 @@ pub fn mob_to_nbt(
|
||||
stack.extra = m.extra.body_extra.clone();
|
||||
}
|
||||
equipment.insert("body".into(), crate::save::item_to_nbt(&stack));
|
||||
} else if crate::mobs::species::body_slot(m.species()) {
|
||||
equipment.remove(&"body".into());
|
||||
}
|
||||
// Vanilla saves `equipment` only when something is equipped.
|
||||
if equipment.is_empty() {
|
||||
@@ -2867,7 +2867,7 @@ pub fn evoker_fangs_to_nbt(
|
||||
motion: raw.map(motion).unwrap_or(Vec3::ZERO),
|
||||
rotation: [yaw, raw.map(rotation).unwrap_or([0.0; 2])[1]],
|
||||
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
|
||||
fall_distance: 0.0,
|
||||
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
|
||||
}
|
||||
.tag(raw);
|
||||
put(&mut tag, "Warmup", Tag::Int(warmup));
|
||||
@@ -4163,6 +4163,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `EvokerFangs` extends `Entity` and never moves: `Entity.saveWithoutId`
|
||||
/// writes the `fall_distance` it loaded, and 0 for fangs a spell made.
|
||||
#[test]
|
||||
fn evoker_fangs_keep_their_loaded_fall_distance() {
|
||||
let raw = horizon_world::nbt::parse_snbt(
|
||||
"{id:\"minecraft:evoker_fangs\",Pos:[1d,200d,1d],fall_distance:5.0d,Warmup:100}",
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let at = super::Vec3::new(1.0, 200.0, 1.0);
|
||||
let fall = |raw: Option<&super::Tag>| match super::evoker_fangs_to_nbt(
|
||||
at, 0.0, 99, None, [1; 4], raw,
|
||||
)
|
||||
.get("fall_distance")
|
||||
{
|
||||
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
|
||||
other => panic!("no fall_distance: {other:?}"),
|
||||
};
|
||||
assert_eq!(fall(Some(&raw)), 5.0);
|
||||
assert_eq!(fall(None), 0.0);
|
||||
}
|
||||
|
||||
/// `Shulker` reads `AttachFace` as a byte before
|
||||
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
|
||||
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
|
||||
@@ -4549,6 +4571,37 @@ mod tests {
|
||||
assert_eq!(id("head"), "minecraft:iron_helmet");
|
||||
}
|
||||
|
||||
/// A body item taken off (`/item replace entity <cube> armor.body with minecraft:air`)
|
||||
/// is not saved again from the entity's earlier save: vanilla writes `equipment.body` only
|
||||
/// while the slot holds something (#12038).
|
||||
#[test]
|
||||
fn an_emptied_body_slot_is_not_saved_from_the_earlier_nbt() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = "minecraft:sulfur_cube".into();
|
||||
ty.species = crate::mobs::Species::SulfurCube;
|
||||
let types = [("minecraft:sulfur_cube".into(), kind)].into();
|
||||
let raw = Tag::compound([
|
||||
("id", string("minecraft:sulfur_cube")),
|
||||
("Pos", doubles(&[4.5, 220.0, 4.5])),
|
||||
(
|
||||
"equipment",
|
||||
Tag::compound([(
|
||||
"body",
|
||||
Tag::compound([
|
||||
("id", string("minecraft:oak_planks")),
|
||||
("count", Tag::Int(1)),
|
||||
]),
|
||||
)]),
|
||||
),
|
||||
]);
|
||||
let mut m = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
assert_eq!(m.extra.body.as_deref(), Some("minecraft:oak_planks"));
|
||||
m.extra.body = None;
|
||||
let saved = mob_to_nbt(&m, [0; 4], Some(&raw), None, |_| None);
|
||||
assert!(saved.get("equipment").is_none(), "{saved:?}");
|
||||
}
|
||||
|
||||
/// A wolf loads its armor (`equipment.body`) into the body slot with the
|
||||
/// durability it lost, and a helmet next to it into the head slot.
|
||||
#[test]
|
||||
@@ -5514,6 +5567,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Goat` reads `HasLeftHorn` and `HasRightHorn` with `getBooleanOr(key,
|
||||
/// true)`: a number is true when its byte value is not 0, so an int 256
|
||||
/// (byte 0) is a lost horn, as `0b` is.
|
||||
#[test]
|
||||
fn a_goats_horn_flags_are_read_as_bytes() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = "minecraft:goat".into();
|
||||
ty.species = crate::mobs::Species::Goat;
|
||||
let types = [("minecraft:goat".into(), kind)].into();
|
||||
let lost = |left: Option<Tag>| {
|
||||
let mut fields = vec![
|
||||
("id", string("minecraft:goat")),
|
||||
("Pos", doubles(&[4.5, 220.0, 4.5])),
|
||||
];
|
||||
fields.extend(left.map(|v| ("HasLeftHorn", v)));
|
||||
mob_from_nbt(&Tag::compound(fields), &types, 1, None)
|
||||
.unwrap()
|
||||
.extra
|
||||
.lost_horns
|
||||
};
|
||||
assert_eq!(lost(None), [false, false]);
|
||||
assert_eq!(lost(Some(Tag::Byte(1))), [false, false]);
|
||||
assert_eq!(lost(Some(Tag::Byte(0))), [true, false]);
|
||||
assert_eq!(lost(Some(Tag::Int(256))), [true, false]);
|
||||
assert_eq!(lost(Some(Tag::Int(257))), [false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
|
||||
let uuid = [104, 0, 0, 1];
|
||||
@@ -8003,6 +8084,14 @@ mod fireball_nbt_tests {
|
||||
("Facing", Tag::Byte(1)),
|
||||
]);
|
||||
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
|
||||
// BlockAttachedEntity.readAdditionalSaveData: a block_pos 16 blocks or
|
||||
// more from the Pos block is refused for that block.
|
||||
let mut far = painting.clone();
|
||||
put(&mut far, "block_pos", Tag::IntArray(vec![28, 80, 22]));
|
||||
assert_eq!(painting_from_nbt(&far).map(|p| p.0), Some([28, 80, 6]));
|
||||
let mut near = painting.clone();
|
||||
put(&mut near, "block_pos", Tag::IntArray(vec![28, 80, 21]));
|
||||
assert_eq!(painting_from_nbt(&near).map(|p| p.0), Some([28, 80, 21]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,7 +13,8 @@ fn loaded_pitch(raw: Option<&Tag>) -> f32 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
|
||||
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`, after
|
||||
/// Entity.saveWithoutId's common fields.
|
||||
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let (lo, hi) = p.bounds();
|
||||
let base = Base {
|
||||
@@ -26,8 +27,12 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
),
|
||||
motion: Vec3::ZERO,
|
||||
rotation: [f32::from(p.facing) * 90.0, loaded_pitch(raw)],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
// BlockAttachedEntity.tick never moves a painting (nor runs baseTick), so its
|
||||
// onGround and fallDistance stay the ones Entity.load read.
|
||||
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
|
||||
fall_distance: raw
|
||||
.and_then(|raw| float(raw.get("fall_distance")))
|
||||
.unwrap_or(0.0),
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put(&mut tag, "facing", Tag::Byte(p.facing as i8));
|
||||
@@ -35,21 +40,37 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
put(&mut tag, "block_pos", Tag::IntArray(p.pos.to_vec()));
|
||||
tag
|
||||
}
|
||||
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved; a
|
||||
/// summoned one has none, and `setPos` puts its block at the block holding
|
||||
/// the summon position.
|
||||
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved and
|
||||
/// closer than 16 blocks to the block holding `Pos`; else (a summoned one
|
||||
/// has none) `setPos` puts it at that block.
|
||||
fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
|
||||
match tag.get("block_pos") {
|
||||
let stored = match tag.get("block_pos") {
|
||||
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
|
||||
_ => {
|
||||
let at = position(tag)?;
|
||||
Some([
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
])
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(at) = position(tag) else {
|
||||
return stored;
|
||||
};
|
||||
let here = [
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
// Vec3i.closerThan(blockPosition(), 16): the squared distance under 256.
|
||||
let near = |p: &[i32; 3]| {
|
||||
(0..3)
|
||||
.map(|i| (f64::from(p[i]) - f64::from(here[i])).powi(2))
|
||||
.sum::<f64>()
|
||||
< 256.0
|
||||
};
|
||||
Some(stored.filter(near).unwrap_or(here))
|
||||
}
|
||||
/// The block position and facing of a saved painting (`HangingEntity`'s
|
||||
/// `facing`, a 2D direction).
|
||||
pub fn painting_placement_from_nbt(tag: &Tag) -> Option<([i32; 3], u8)> {
|
||||
let pos = attached_block_pos(tag)?;
|
||||
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
|
||||
Some((pos, facing))
|
||||
}
|
||||
/// `Direction.from3DDataValue` / `from2DDataValue`: the byte read by
|
||||
/// `LEGACY_ID_CODEC` indexes the table as `abs(data % modulus)`, never clamped.
|
||||
@@ -59,8 +80,7 @@ fn wrap_data_value(raw: impl TryInto<i64> + Copy, modulus: i64) -> u8 {
|
||||
}
|
||||
/// The block position, facing and variant (if saved) of a painting.
|
||||
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, Option<String>)> {
|
||||
let pos = attached_block_pos(tag)?;
|
||||
let facing = wrap_data_value(number(tag.get("facing")).unwrap_or(0), 4);
|
||||
let (pos, facing) = painting_placement_from_nbt(tag)?;
|
||||
Some((pos, facing, text(tag.get("variant"))))
|
||||
}
|
||||
/// ItemFrame.addAdditionalSaveData.
|
||||
@@ -74,10 +94,14 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
|
||||
(lo[1] + hi[1]) / 2.0,
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
),
|
||||
motion: Vec3::ZERO,
|
||||
// BlockAttachedEntity.tick never runs super.tick, so the Motion, onGround and
|
||||
// fallDistance that Entity.load read stay.
|
||||
motion: raw.map_or(Vec3::ZERO, loaded_motion),
|
||||
rotation: [0.0, 0.0],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
on_ground: raw.is_some_and(|raw| flag(raw, "OnGround")),
|
||||
fall_distance: raw
|
||||
.and_then(|raw| float(raw.get("fall_distance")))
|
||||
.unwrap_or(0.0),
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
// `Entity.saveWithoutId`: `CustomName` before the frame's own fields.
|
||||
@@ -148,4 +172,27 @@ mod tests {
|
||||
]);
|
||||
assert_eq!(painting_from_nbt(&tag).unwrap().1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_frame_keeps_loaded_motion_on_ground_and_fall_distance() {
|
||||
let tag = Tag::compound([
|
||||
("id", string("minecraft:item_frame")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Facing", Tag::Byte(2)),
|
||||
("Motion", doubles(&[0.5, 20.0, -0.25])),
|
||||
("OnGround", Tag::Byte(1)),
|
||||
("fall_distance", Tag::Double(3.5f64.to_bits())),
|
||||
]);
|
||||
let frame = item_frame_from_nbt(&tag, 1).unwrap();
|
||||
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&tag));
|
||||
// A component beyond 10 loads as 0, the others stay.
|
||||
assert_eq!(motion(&saved), Vec3::new(0.5, 0.0, -0.25));
|
||||
assert!(flag(&saved, "OnGround"));
|
||||
assert_eq!(float(saved.get("fall_distance")), Some(3.5));
|
||||
// A frame made in play has nothing loaded: zeros.
|
||||
let made = item_frame_to_nbt(&frame, [1, 2, 3, 4], None);
|
||||
assert_eq!(motion(&made), Vec3::ZERO);
|
||||
assert!(!flag(&made, "OnGround"));
|
||||
assert_eq!(float(made.get("fall_distance")), Some(0.0));
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,12 @@ pub fn settings_from_json(
|
||||
impl ChunkGenerator for FlatTerrain {
|
||||
type Worker = ();
|
||||
fn worker(&self) {}
|
||||
/// FlatLevelSource's proto is already its decorated column. Keep a copy
|
||||
/// for lighting a finished neighbour outside the kept area, just as the
|
||||
/// vanilla pipeline does for FEATURES neighbours.
|
||||
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
|
||||
proto.downcast_ref::<Chunk>().cloned()
|
||||
}
|
||||
fn lod_surface(&self, _: &mut (), _: i32, _: i32) -> Option<crate::lod::Surface> {
|
||||
self.surface
|
||||
}
|
||||
@@ -610,4 +616,21 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_view_keeps_a_flat_proto_column() {
|
||||
let terrain = FlatTerrain {
|
||||
shape: LevelShape::OVERWORLD,
|
||||
layers: Vec::new(),
|
||||
biome: 0,
|
||||
surface: None,
|
||||
void_platform: None,
|
||||
};
|
||||
let mut chunk = Chunk::empty(3, -4);
|
||||
chunk.set(0, 0, 0, 42);
|
||||
let proto: crate::streaming::Proto = Box::new(chunk);
|
||||
let view = terrain.light_view(&proto).expect("flat proto light view");
|
||||
assert_eq!((view.x, view.z), (3, -4));
|
||||
assert_eq!(view.block(0, 0, 0).0, 42);
|
||||
}
|
||||
}
|
||||
@@ -476,7 +476,6 @@ mod trial_spawner;
|
||||
mod trial_visuals;
|
||||
mod trident;
|
||||
mod tutorial;
|
||||
mod tweaks;
|
||||
mod ui_control;
|
||||
mod use_hand;
|
||||
mod using_item;
|
||||
@@ -2230,7 +2229,7 @@ struct Game<'a> {
|
||||
/// `AbstractContainerScreen`'s quick craft and skip-release state.
|
||||
inv_clicks: horizon_client::container_screen::Clicks,
|
||||
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
|
||||
tweaks: tweaks::Tweaks,
|
||||
tweaks: extras::mouse_tweaks::Tweaks,
|
||||
/// The wheel and hovered slot of `BundleMouseActions`.
|
||||
bundle_mouse: bundles::BundleMouse,
|
||||
/// The last inventory click, for the double click that gathers a stack.
|
||||
@@ -7211,14 +7210,14 @@ impl Game<'_> {
|
||||
laps.lap("eg_objects");
|
||||
// Particles (ParticleEngine.extract): camera-facing quads.
|
||||
self.mark_stage(out.len(), "PARTICLES");
|
||||
let world = &self.world;
|
||||
let particle_mode = self.settings.horizon.particle_culling;
|
||||
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
|
||||
self.particles.render(
|
||||
self.particles.render_in_view(
|
||||
camera,
|
||||
self.particle_rotation(),
|
||||
alpha as f32,
|
||||
&|b| world.light(b[0], b[1], b[2]),
|
||||
self.view_frustum.as_ref(),
|
||||
&|b| self.world.light(b[0], b[1], b[2]),
|
||||
&mut out,
|
||||
);
|
||||
} else {
|
||||
@@ -7241,7 +7240,7 @@ impl Game<'_> {
|
||||
true,
|
||||
frustum.as_ref(),
|
||||
&mut |min, max| particle_culling.hidden(min, max),
|
||||
&|b| world.light(b[0], b[1], b[2]),
|
||||
&|b| self.world.light(b[0], b[1], b[2]),
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
@@ -8189,19 +8188,16 @@ impl Game<'_> {
|
||||
.ok_or("missing lit block state")?;
|
||||
state.properties.insert("lit".into(), "true".into());
|
||||
self.level_set(hit.pos, registry.resolve(&state)?)?;
|
||||
self.audio.play(
|
||||
if item == "fire_charge" {
|
||||
"minecraft:item.firecharge.use"
|
||||
} else {
|
||||
"minecraft:item.flintandsteel.use"
|
||||
},
|
||||
Some(at(hit.pos)),
|
||||
1.0,
|
||||
horizon_client::items::fire_starter_pitch(
|
||||
item == "fire_charge",
|
||||
&mut self.world.random,
|
||||
),
|
||||
let sound = if item == "fire_charge" {
|
||||
"minecraft:item.firecharge.use"
|
||||
} else {
|
||||
"minecraft:item.flintandsteel.use"
|
||||
};
|
||||
let pitch = horizon_client::items::fire_starter_pitch(
|
||||
item == "fire_charge",
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.play_block_use_sound(sound, at(hit.pos), pitch);
|
||||
// FlintAndSteelItem / FireChargeItem.useOn: BLOCK_CHANGE at the lit block,
|
||||
// and ItemStack.useOn's used statistic.
|
||||
self.post_block_game_event(
|
||||
@@ -8232,7 +8228,7 @@ impl Game<'_> {
|
||||
item == "fire_charge",
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.audio.play(sound, Some(at(target)), 1.0, pitch);
|
||||
self.play_block_use_sound(sound, at(target), pitch);
|
||||
// BLOCK_PLACE: flint and steel names the clicked block, a fire charge the
|
||||
// fire. Then ItemStack.useOn's used statistic.
|
||||
let placed_at = if item == "fire_charge" {
|
||||
@@ -12590,6 +12586,35 @@ mod subtitles;
|
||||
#[cfg(test)]
|
||||
#[path = "game_tests.rs"]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod test_neighbourhood {
|
||||
//! Shared fixture step for tests that tick entities on prepared data.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Loads the chunks within 2 columns of `(cx, cz)` that are not loaded yet.
|
||||
/// Entity ticking waits for every chunk in that 5x5 neighbourhood
|
||||
/// (`ChunkMap.prepareEntityTickingChunk`), so a fixture that ticks mobs,
|
||||
/// items or TNT in a chunk calls this after setting up its own chunks.
|
||||
pub(crate) fn load_ticking_neighbourhood(&mut self, cx: i32, cz: i32) {
|
||||
for z in cz - 2..=cz + 2 {
|
||||
for x in cx - 2..=cx + 2 {
|
||||
if self.world.chunks.contains_key(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
self.world.chunks.insert(
|
||||
(x, z),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(x, z)),
|
||||
);
|
||||
// A chunk that is already registered keeps its ticks.
|
||||
let _ = self
|
||||
.block_ticks
|
||||
.add_empty_chunk(horizon_core::position::ChunkPos::new(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_level_for_role(
|
||||
level: &mut Level,
|
||||
|
||||
@@ -51,6 +51,7 @@ fn game() -> Game<'static> {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"fill 0 99 0 15 99 15 stone",
|
||||
|
||||
@@ -2709,6 +2709,7 @@ mod postbaseline_lodestone_controls {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(64, 0, 64).chunk())
|
||||
.unwrap();
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
assert!(game.run_command("fill 64 199 64 79 199 79 stone"));
|
||||
assert!(game.run_command(&format!("summon armor_stand 65.5 200.0 65.5 {{UUID:[I;42321,0,0,102],Health:20f,NoGravity:1b,equipment:{{mainhand:{}}}}}",item("minecraft:compass",tracked,100,1))));
|
||||
let id = |n| {
|
||||
|
||||
@@ -1508,7 +1508,6 @@ impl Game<'_> {
|
||||
Vec3::new(b.position.x, top, b.position.z)
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
/// `Entity.setYRot` on a boat just made (its old rotation moves with it).
|
||||
pub(super) fn set_boat_yaw(&mut self, id: u32, yaw: f32) {
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
@@ -1516,6 +1515,7 @@ impl Game<'_> {
|
||||
b.previous_yaw = yaw;
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(boat) = self.boat_to_place(stack) else {
|
||||
return false;
|
||||
@@ -1528,7 +1528,7 @@ impl Game<'_> {
|
||||
self.world.modified.insert(key);
|
||||
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
|
||||
self.boats.push(boat);
|
||||
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
|
||||
self.apply_item_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
|
||||
// BoatItem.use: setYRot(player.getYRot()) after the stack's config.
|
||||
self.set_boat_yaw(id, yaw);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
|
||||
@@ -54,6 +54,9 @@ fn game() -> Game<'static> {
|
||||
.chunks
|
||||
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
|
||||
}
|
||||
for x in 0..2 {
|
||||
game.load_ticking_neighbourhood(x, 0);
|
||||
}
|
||||
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 6.5)).unwrap());
|
||||
let net = NetConfig {
|
||||
|
||||
@@ -4,6 +4,7 @@ mod admin;
|
||||
pub(super) mod compute;
|
||||
mod data;
|
||||
mod display_names;
|
||||
mod target_position;
|
||||
pub(super) use data::place_summoned;
|
||||
mod effects;
|
||||
pub(super) mod execute;
|
||||
@@ -451,9 +452,7 @@ impl Game<'_> {
|
||||
// the server's own source, "Server".
|
||||
let who = match self.command_source.and_then(|s| s.entity) {
|
||||
Some(entity) => self.target_name(entity),
|
||||
None if self.console_broadcast.is_some() => {
|
||||
Text::literal(if self.rcon_source { "Rcon" } else { "Server" }.to_owned())
|
||||
}
|
||||
None if self.console_broadcast.is_some() => self.server_name(),
|
||||
None => self.player_display_name(),
|
||||
};
|
||||
let line = Text::translate("chat.type.admin", &[who, message])
|
||||
@@ -538,118 +537,6 @@ impl Game<'_> {
|
||||
conn.and_then(|c| self.command_guest(c))
|
||||
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
|
||||
}
|
||||
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
|
||||
match t {
|
||||
Target::Player => self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]),
|
||||
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z]),
|
||||
Target::Item(id) => self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| [i.position.x, i.position.y, i.position.z]),
|
||||
Target::Orb(id) => self
|
||||
.orbs
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| [a.position.x, a.position.y, a.position.z]),
|
||||
Target::Stand(id) => self
|
||||
.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| [a.position.x, a.position.y, a.position.z]),
|
||||
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
|
||||
let (lo, hi) = f.bounds();
|
||||
[
|
||||
(lo[0] + hi[0]) / 2.0,
|
||||
(lo[1] + hi[1]) / 2.0,
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
]
|
||||
}),
|
||||
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
|
||||
let (lo, hi) = p.bounds();
|
||||
[
|
||||
(lo[0] + hi[0]) / 2.0,
|
||||
(lo[1] + hi[1]) / 2.0,
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
]
|
||||
}),
|
||||
Target::Boat(id) => self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| [b.position.x, b.position.y, b.position.z]),
|
||||
Target::Cart(id) => self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Other(id) => self
|
||||
.others
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Crystal(id) => self
|
||||
.crystals
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Dragon(id) => self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|d| [d.position.x, d.position.y, d.position.z]),
|
||||
Target::Cushion(id) => self
|
||||
.cushions
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Tnt(id) => self
|
||||
.tnts
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]),
|
||||
Target::Falling(id) => self
|
||||
.falling
|
||||
.iter()
|
||||
.find(|f| f.id == id)
|
||||
.map(|f| [f.position.x, f.position.y, f.position.z]),
|
||||
Target::Rocket(id) => self
|
||||
.rockets
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| [r.position.x, r.position.y, r.position.z]),
|
||||
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
|
||||
Target::Trident(id) => self
|
||||
.tridents
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]),
|
||||
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
|
||||
Target::Spawner(id) => self
|
||||
.ominous_spawners
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.position),
|
||||
Target::Knot(id) => self.natural_knot(id).map(|fence| {
|
||||
let p = horizon_client::mobs::leash::knot_state(fence).position;
|
||||
[p.x, p.y, p.z]
|
||||
}),
|
||||
}
|
||||
}
|
||||
pub(super) fn target_type(&self, t: Target) -> String {
|
||||
match t {
|
||||
Target::Player | Target::Guest(_) => "minecraft:player".into(),
|
||||
@@ -1851,7 +1738,7 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
|
||||
c.position = v;
|
||||
(c.position, c.fall.0) = (v, true); // performTeleport: setOnGround(true)
|
||||
}
|
||||
}
|
||||
Target::Dragon(id) => {
|
||||
@@ -1862,6 +1749,8 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Tnt(id) => {
|
||||
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
|
||||
// `PrimedTnt.teleport` includes same-level command teleports.
|
||||
t.used_portal = true;
|
||||
t.position = v;
|
||||
t.previous = v;
|
||||
if let Some(r) = rotation {
|
||||
@@ -2635,14 +2524,9 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
let at = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
self.crystals.push(super::dragon::Crystal {
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
show_bottom: true,
|
||||
time: super::dragon::Crystal::start_time(at, drawn, self.clock.still()),
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
let time = super::dragon::Crystal::start_time(at, drawn, self.clock.still());
|
||||
let crystal = super::dragon::Crystal::new(self.next_entity, at, true, time);
|
||||
self.crystals.push(crystal);
|
||||
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
|
||||
if !self.native_joins_flush(false, self.next_entity) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
@@ -4412,7 +4296,7 @@ impl Game<'_> {
|
||||
.map(|cart| cart.command.name())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| Text::literal("Server")),
|
||||
.unwrap_or_else(|| self.server_name()),
|
||||
},
|
||||
None => self.player_display_name(),
|
||||
};
|
||||
|
||||
@@ -697,6 +697,27 @@ impl Game<'_> {
|
||||
self.carts[i] = cart;
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
Target::Painting(id) => {
|
||||
// Painting.readAdditionalSaveData over HangingEntity's: the
|
||||
// facing, the block it hangs on and a known variant
|
||||
// (`VariantUtils.readVariant(..).ifPresent`: an unknown or
|
||||
// unreadable one keeps the painting's).
|
||||
let uuid = self.uuid_for(id);
|
||||
let (pos, facing) =
|
||||
entity_nbt::painting_placement_from_nbt(&tag).ok_or_else(stale)?;
|
||||
let known = entity_nbt::painting_from_nbt(&tag)
|
||||
.and_then(|(_, _, v)| self.painting_variants.get(&v?).cloned());
|
||||
let p = self
|
||||
.paintings
|
||||
.iter_mut()
|
||||
.find(|p| p.id == id)
|
||||
.ok_or_else(stale)?;
|
||||
(p.pos, p.facing) = (pos, facing);
|
||||
if let Some(v) = known {
|
||||
(p.variant, p.width, p.height) = (v.id, v.width, v.height);
|
||||
}
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
Target::Other(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let marker = self
|
||||
@@ -748,6 +769,7 @@ impl Game<'_> {
|
||||
| Target::Item(id)
|
||||
| Target::Stand(id)
|
||||
| Target::Frame(id)
|
||||
| Target::Painting(id)
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Tnt(id)
|
||||
@@ -1545,7 +1567,7 @@ impl Game<'_> {
|
||||
let before = self.next_entity;
|
||||
let mut tag = tag;
|
||||
crate::game::persist::mark_fresh(&mut tag);
|
||||
self.world.loaded_entities.push((key, vec![tag]));
|
||||
self.world.queue_entity_nbt(key, vec![tag]);
|
||||
self.spawn_loaded_entities();
|
||||
waiting.append(&mut self.world.loaded_entities);
|
||||
self.world.loaded_entities = waiting;
|
||||
|
||||
@@ -61,6 +61,12 @@ impl Game<'_> {
|
||||
.unwrap_or(Target::Player)
|
||||
}
|
||||
|
||||
/// The name of the server's own source: "Rcon" for a command an RCON client sent
|
||||
/// (`RconConsoleSource`), "Server" otherwise (`MinecraftServer.createCommandSourceStack`).
|
||||
pub(in crate::game) fn server_name(&self) -> Text {
|
||||
Text::literal(if self.rcon_source { "Rcon" } else { "Server" })
|
||||
}
|
||||
|
||||
/// `PlayerList.getPlayerByName`: names are not case sensitive. Horizon:
|
||||
/// on a server where a mod gave players nicknames
|
||||
/// (`native_set_nickname`), a name no player has finds the player going
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::{Game, Target};
|
||||
|
||||
impl Game<'_> {
|
||||
pub(in crate::game) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
|
||||
match t {
|
||||
Target::Player => self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]),
|
||||
Target::Guest(conn) => self.command_guest(conn).map(|g| g.position),
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z]),
|
||||
Target::Item(id) => self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| [i.position.x, i.position.y, i.position.z]),
|
||||
Target::Orb(id) => self
|
||||
.orbs
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| [a.position.x, a.position.y, a.position.z]),
|
||||
Target::Stand(id) => self
|
||||
.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| [a.position.x, a.position.y, a.position.z]),
|
||||
Target::Frame(id) => self.item_frames.iter().find(|f| f.id == id).map(|f| {
|
||||
let (lo, hi) = f.bounds();
|
||||
[
|
||||
(lo[0] + hi[0]) / 2.0,
|
||||
(lo[1] + hi[1]) / 2.0,
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
]
|
||||
}),
|
||||
Target::Painting(id) => self.paintings.iter().find(|p| p.id == id).map(|p| {
|
||||
let (lo, hi) = p.bounds();
|
||||
[
|
||||
(lo[0] + hi[0]) / 2.0,
|
||||
(lo[1] + hi[1]) / 2.0,
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
]
|
||||
}),
|
||||
Target::Boat(id) => self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| [b.position.x, b.position.y, b.position.z]),
|
||||
Target::Cart(id) => self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Other(id) => self
|
||||
.others
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Crystal(id) => self
|
||||
.crystals
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Dragon(id) => self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|d| [d.position.x, d.position.y, d.position.z]),
|
||||
Target::Cushion(id) => self
|
||||
.cushions
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Tnt(id) => self
|
||||
.tnts
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]),
|
||||
Target::Falling(id) => self
|
||||
.falling
|
||||
.iter()
|
||||
.find(|f| f.id == id)
|
||||
.map(|f| [f.position.x, f.position.y, f.position.z]),
|
||||
Target::Rocket(id) => self
|
||||
.rockets
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| [r.position.x, r.position.y, r.position.z]),
|
||||
Target::Cloud(id) => self.breath_clouds.iter().find(|c| c.id == id).map(|c| c.at),
|
||||
Target::Trident(id) => self
|
||||
.tridents
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]),
|
||||
Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos),
|
||||
Target::Spawner(id) => self
|
||||
.ominous_spawners
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.position),
|
||||
Target::Knot(id) => self.natural_knot(id).map(|fence| {
|
||||
let p = horizon_client::mobs::leash::knot_state(fence).position;
|
||||
[p.x, p.y, p.z]
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1080,7 +1080,7 @@ impl Game<'_> {
|
||||
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
|
||||
dimension_type: self.dimension_type(),
|
||||
world_seed: self.level.seed,
|
||||
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
|
||||
sea_level: horizon_world::level::sea_level_of(self.level_key()),
|
||||
teams: std::sync::Arc::new(self.mob_teams()),
|
||||
items: Some(self.items()),
|
||||
lid_openers: &self.lid_openers,
|
||||
@@ -1280,10 +1280,9 @@ impl Game<'_> {
|
||||
.is_some()
|
||||
.then_some(self.level.player_uuid)
|
||||
.flatten(),
|
||||
mods: round.listening() || !horizon_client::mod_api::mods().is_empty(),
|
||||
ticking: &|| Box::new(self.entity_ticking_area()),
|
||||
};
|
||||
let mut group = match islands.plan(&mobs, &snapshot, &turns) {
|
||||
let mut group = match islands.plan(&mobs, &snapshot, &turns, &start) {
|
||||
Some(plan) => islands.run(plan, mobs.into(), snapshot, start, &mut turns, &shared),
|
||||
None => {
|
||||
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
|
||||
@@ -1878,10 +1877,9 @@ impl Game<'_> {
|
||||
for item in stacks {
|
||||
// Every death drop is its own ItemEntity, and the drop entity's
|
||||
// own random (not the level's) gives it the constructor's
|
||||
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
|
||||
// the drops land stacked at the mob's centre, without disturbing
|
||||
// the level random that the orbs' merge ids and block ticks read
|
||||
// next).
|
||||
// horizontal push (its motion otherwise stays [0, 0.2, 0] and the
|
||||
// drops land stacked at the mob's centre, without disturbing the
|
||||
// level random that the orbs' merge ids and block ticks read next).
|
||||
self.spawn_item_own(item, pos, 10);
|
||||
}
|
||||
// LivingEntity.dropExperience: a mod may change the mob's experience
|
||||
@@ -1921,8 +1919,9 @@ impl Game<'_> {
|
||||
self.spawn_item_own(item, pos, 10);
|
||||
}
|
||||
}
|
||||
drops::Drop::Aged(s, age) => self.spawn_item_aged(from_loot(&s), pos, age),
|
||||
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
|
||||
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3234,11 +3233,7 @@ impl Game<'_> {
|
||||
MobEvent::DolphinTreasure { mob } => {
|
||||
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
|
||||
let Some(at) = self.mobs.iter().find(|m| m.id == mob).map(|m| {
|
||||
[
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
]
|
||||
[m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i32)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
@@ -3249,7 +3244,11 @@ impl Game<'_> {
|
||||
.filter(|_| !names.is_empty())
|
||||
.and_then(|t| t.locate(&names, at, 50));
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
m.extra.home = found.map(|(_, p)| p);
|
||||
m.extra.home = found.as_ref().map(|(_, p)| *p);
|
||||
}
|
||||
// DolphinSwimToTreasureGoal.start: entity event 38 once it has a treasure.
|
||||
if found.is_some() {
|
||||
self.mob_event_particles(mob, 38);
|
||||
}
|
||||
}
|
||||
MobEvent::EffectPlayer {
|
||||
@@ -9051,6 +9050,7 @@ mod h169_glide_travel_tests {
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
|
||||
|
||||
@@ -64,6 +64,9 @@ pub(super) struct Group<M: MobSlot> {
|
||||
pub corpses: Vec<Mob>,
|
||||
pub gone: Vec<Gone>,
|
||||
pub guest_events: Vec<(u32, u32, MobEvent)>,
|
||||
/// The mod hooks its turns kept for the join, with each turn's place in the whole list (a
|
||||
/// group of the parallel tick with mods loaded: `native_client_hooks::Deferred`).
|
||||
pub deferred: Vec<(usize, horizon_client::native_client_hooks::Deferred)>,
|
||||
}
|
||||
|
||||
/// What decides each turn besides the group, read at the turn as the serial loop reads it.
|
||||
@@ -77,7 +80,7 @@ pub(super) struct Turns<'t, 'w> {
|
||||
/// The one player's position, when the level has one: every mob's nearest.
|
||||
pub single_player: Option<Option<Vec3>>,
|
||||
/// Whether a mod canceled a mob's tick.
|
||||
pub canceled: &'t dyn Fn(u32) -> bool,
|
||||
pub canceled: &'t (dyn Fn(u32) -> bool + Sync),
|
||||
/// `getCurrentDifficultyAt` a position.
|
||||
pub difficulty: &'t (dyn Fn([f64; 3]) -> LocalDifficulty + Sync),
|
||||
/// The last chunk's difficulty: mobs come in runs of one chunk.
|
||||
@@ -137,6 +140,7 @@ impl<M: MobSlot> Group<M> {
|
||||
corpses: Vec::new(),
|
||||
gone: Vec::new(),
|
||||
guest_events: Vec::new(),
|
||||
deferred: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +177,17 @@ impl<M: MobSlot> Group<M> {
|
||||
foreign.reader.set(at);
|
||||
}
|
||||
if let Some(foreign) = foreign {
|
||||
foreign.in_seed_order(at, || self.turn(i, turns, shared));
|
||||
foreign.in_seed_order(at, || {
|
||||
if foreign.defers {
|
||||
let ((), kept) =
|
||||
horizon_client::native_client_hooks::deferring_turn(|| {
|
||||
self.turn(i, turns, shared);
|
||||
});
|
||||
self.deferred.extend(kept.into_iter().map(|d| (at, d)));
|
||||
} else {
|
||||
self.turn(i, turns, shared);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
self.turn(i, turns, shared);
|
||||
}
|
||||
@@ -363,7 +377,10 @@ impl<M: MobSlot> Group<M> {
|
||||
shared.guest_ids,
|
||||
self.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
// A grouped turn runs while no mod hooks the change (`horizon_mod_api_gen::mob_turn`).
|
||||
if let Some((player, mob)) = named
|
||||
&& !horizon_client::native_client_hooks::deferring()
|
||||
{
|
||||
let kind = super::super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Game::native_living_change_target(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,9 +5,14 @@ use super::super::{
|
||||
};
|
||||
use horizon_client::{mobs::Species, scoreboard::teams_allow_damage};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::play::{Clientbound, GameEvent};
|
||||
|
||||
/// `ClientboundGameEventPacket.PUFFER_FISH_STING`.
|
||||
const PUFFER_FISH_STING: u8 = 9;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Pufferfish.playerTouch`: accepted player damage, then poison with this fish as source.
|
||||
/// `Pufferfish.playerTouch`: accepted player damage, the sting game event, then poison with
|
||||
/// this fish as source.
|
||||
pub(in crate::game) fn touch_pufferfish(&mut self) {
|
||||
if self.remote.is_some() || self.spectator() {
|
||||
return;
|
||||
@@ -46,10 +51,11 @@ impl Game<'_> {
|
||||
m.extra.size,
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
self.mob_entity(m),
|
||||
m.silent,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (id, puff, at, source) in touching {
|
||||
for (id, puff, at, source, silent) in touching {
|
||||
if !self.hurt_player_from(
|
||||
(1 + puff) as f32,
|
||||
Some(at),
|
||||
@@ -58,6 +64,10 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// `if (!this.isSilent())` inside the `hurtServer` branch: before the poison.
|
||||
if !silent {
|
||||
self.puffer_fish_sting();
|
||||
}
|
||||
let changes = self.potions.effects.changes;
|
||||
self.give_player_effect("minecraft:poison", 60 * puff, 0);
|
||||
if self.potions.effects.changes != changes {
|
||||
@@ -65,4 +75,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.connection.send(ClientboundGameEventPacket(PUFFER_FISH_STING, 0.0F))`: the
|
||||
/// stung player's own client plays it (`ClientPacketListener.handleGameEvent`). A server has
|
||||
/// no player of its own, so the packet is the only way to reach it.
|
||||
fn puffer_fish_sting(&mut self) {
|
||||
self.guest_packets.push(Clientbound::GameEvent(GameEvent {
|
||||
event: PUFFER_FISH_STING,
|
||||
param: 0.0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pufferfish_sting_tests.rs"]
|
||||
mod pufferfish_sting_tests;
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `Pufferfish.playerTouch`: the sting game event (`ClientboundGameEventPacket.PUFFER_FISH_STING`)
|
||||
//! goes out only inside the `hurtServer` branch, and only when the fish is not
|
||||
//! silent; the poison follows it.
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::play::Clientbound;
|
||||
|
||||
/// A game whose window player touches one puffed (state 2) pufferfish. The
|
||||
/// server acts for the guest `lan_acting`: `playerTouch` runs there, and the
|
||||
/// event it sends reaches that player's own client.
|
||||
fn game_with(silent: bool) -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
false,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.creative = false;
|
||||
game.lan_acting = Some(7);
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 66.0, 8.5)).unwrap());
|
||||
let kind = game.res.mob_types()["minecraft:pufferfish"].clone();
|
||||
let mut fish = Mob::new(9001, kind, Vec3::new(8.5, 66.0, 8.5), 0.0);
|
||||
fish.extra.size = 2;
|
||||
fish.silent = silent;
|
||||
game.mobs.push(fish);
|
||||
game
|
||||
}
|
||||
|
||||
/// The game events the stung player's client was sent, as `(event, parameter)`.
|
||||
fn stings(game: &Game<'_>) -> Vec<(u8, f32)> {
|
||||
game.guest_packets
|
||||
.iter()
|
||||
.filter_map(|packet| match packet {
|
||||
Clientbound::GameEvent(e) => Some((e.event, e.param)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn health(game: &Game<'_>) -> f32 {
|
||||
game.player.as_ref().unwrap().health.health()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_sting_event_goes_out_once_per_accepted_hit() {
|
||||
let mut g = game_with(false);
|
||||
g.touch_pufferfish();
|
||||
assert_eq!(health(&g), 17.0, "1 + PuffState 2 of damage landed");
|
||||
assert_eq!(stings(&g), vec![(9, 0.0)], "PUFFER_FISH_STING, parameter 0");
|
||||
let poison = g.potions.effects.get("poison").expect("poisoned");
|
||||
assert_eq!((poison.duration, poison.amplifier), (120, 0));
|
||||
// A second touch inside the hurt cooldown is refused (`hurtServer` is
|
||||
// false): no damage and no event.
|
||||
g.touch_pufferfish();
|
||||
assert_eq!(health(&g), 17.0, "the cooldown refused the second hit");
|
||||
assert_eq!(stings(&g).len(), 1, "a refused hit sends nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_silent_fish_damages_and_poisons_without_the_sting() {
|
||||
let mut g = game_with(true);
|
||||
g.touch_pufferfish();
|
||||
assert_eq!(health(&g), 17.0, "the damage still lands");
|
||||
assert!(g.potions.effects.get("poison").is_some(), "the poison too");
|
||||
assert!(stings(&g).is_empty(), "a silent fish sends no event");
|
||||
}
|
||||
@@ -308,6 +308,17 @@ fn overload_warning(behind: Duration, tick: Duration) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.tick`'s idle kick: a player whose last action is more than
|
||||
/// `minutes` (`player-idle-timeout`, none when 0 or less) behind, counted here in ticks, is kicked,
|
||||
/// unless the End poem is showing (`ServerPlayer.wonGame`): its client sends no action until it
|
||||
/// asks to respawn.
|
||||
fn idles_out(idle_ticks: u64, minutes: i32, won_game: bool) -> bool {
|
||||
let Ok(minutes @ 1..) = u64::try_from(minutes) else {
|
||||
return false;
|
||||
};
|
||||
!won_game && idle_ticks > minutes * 1200
|
||||
}
|
||||
|
||||
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
|
||||
pub(super) fn log(level: &str, message: &str) {
|
||||
log_from("Server thread", level, message);
|
||||
@@ -528,6 +539,28 @@ fn parse_whitelist_bypass(text: &str) -> std::result::Result<Vec<String>, String
|
||||
Ok(out)
|
||||
}
|
||||
impl Lists {
|
||||
/// The console's `/ban` of one player (`BanPlayerCommands.banPlayers`): whether they are banned now.
|
||||
/// One banned already (`isBanned`) is left as they are, their first reason kept.
|
||||
fn ban(&mut self, user: &NameAndId, reason: Option<String>) -> bool {
|
||||
!self.bans.contains(user)
|
||||
&& self.bans.add(BanEntry::new(
|
||||
user.clone(),
|
||||
Some("Server".into()),
|
||||
None,
|
||||
reason,
|
||||
))
|
||||
}
|
||||
|
||||
/// The console's `/ban-ip` (`BanIpCommands.banIp`): whether the address is banned now. One banned
|
||||
/// already (`isBanned`) is left as it is.
|
||||
fn ban_ip(&mut self, ip: &str, reason: Option<String>) -> bool {
|
||||
let ip = ip.to_owned();
|
||||
!self.ip_bans.contains(&ip)
|
||||
&& self
|
||||
.ip_bans
|
||||
.add(BanEntry::new(ip, Some("Server".into()), None, reason))
|
||||
}
|
||||
|
||||
fn load() -> Self {
|
||||
let mut l = Self {
|
||||
ops: OpList::new("ops.json"),
|
||||
@@ -864,6 +897,28 @@ fn auto_save(state: &ServerState) -> bool {
|
||||
state.lists.lock().unwrap().autosave
|
||||
}
|
||||
|
||||
/// `MinecraftServer.tickServer`'s pause-when-empty count. `pause_after` is its
|
||||
/// `pauseWhenEmptySeconds() * 20`; while that is above 0, a tick with no player and no
|
||||
/// `/tick sprint` running (`ServerTickRateManager.isSprinting`) adds one to `empty_ticks`, and any
|
||||
/// other tick sets it back to 0. A `/pregen` task keeps the server busy as a player would: paused,
|
||||
/// nothing would post-process its chunks. Returns whether the server is paused this tick.
|
||||
fn count_empty_tick(
|
||||
empty_ticks: &mut i32,
|
||||
pause_after: i32,
|
||||
players: usize,
|
||||
pregen: bool,
|
||||
sprinting: bool,
|
||||
) -> bool {
|
||||
if pause_after > 0 {
|
||||
if players == 0 && !pregen && !sprinting {
|
||||
*empty_ticks += 1;
|
||||
} else {
|
||||
*empty_ticks = 0;
|
||||
}
|
||||
}
|
||||
pause_after > 0 && *empty_ticks >= pause_after
|
||||
}
|
||||
|
||||
/// `MinecraftServer.setAutoSave`: enable or disable periodic saves and report whether it changed.
|
||||
pub(super) fn set_auto_save(lists: &Mutex<Lists>, enabled: bool) -> bool {
|
||||
let mut l = lists.lock().unwrap();
|
||||
@@ -1048,9 +1103,17 @@ impl Game<'_> {
|
||||
// now rather than at the first tick (reading parses functions; it runs none), and a broken
|
||||
// predicate graph stops the start before anything listens (a partial check: predicate_graph.rs).
|
||||
self.ensure_datapacks();
|
||||
if let Err(error) = predicate_graph::check(&self.fns.store.predicates) {
|
||||
let store = &self.fns.store;
|
||||
let tag = |id: &str| {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
store
|
||||
.files
|
||||
.contains_key(&format!("tags/{ns}:predicate/{path}"))
|
||||
};
|
||||
if let Err(error) = predicate_graph::check(&store.predicates, tag) {
|
||||
let level = match error {
|
||||
predicate_graph::GraphError::Unbound(_) => "ERROR",
|
||||
predicate_graph::GraphError::Unbound(_)
|
||||
| predicate_graph::GraphError::UnboundTags(_) => "ERROR",
|
||||
predicate_graph::GraphError::Recursive(_) => "WARN",
|
||||
};
|
||||
for line in error.lines() {
|
||||
@@ -1371,16 +1434,13 @@ impl Game<'_> {
|
||||
// Vanilla reads pauseWhenEmptySeconds on every tick, so management changes take effect
|
||||
// without restarting the server.
|
||||
let pause_after = state.pause_when_empty_seconds.wrapping_mul(20);
|
||||
if pause_after > 0 {
|
||||
// A `/pregen` task keeps the server busy as a player would:
|
||||
// paused, nothing would post-process its chunks.
|
||||
if players == 0 && self.pregen_level().is_none() {
|
||||
state.empty_ticks += 1;
|
||||
} else {
|
||||
state.empty_ticks = 0;
|
||||
}
|
||||
}
|
||||
let paused = pause_after > 0 && state.empty_ticks >= pause_after;
|
||||
let paused = count_empty_tick(
|
||||
&mut state.empty_ticks,
|
||||
pause_after,
|
||||
players,
|
||||
self.pregen_level().is_some(),
|
||||
self.clock.sprinting(),
|
||||
);
|
||||
state.paused = paused;
|
||||
if paused && state.empty_ticks == pause_after {
|
||||
log(
|
||||
@@ -1578,12 +1638,15 @@ impl Game<'_> {
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let limit = u64::try_from(minutes).unwrap_or(0) * 1200;
|
||||
let idle: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.ticks.saturating_sub(g.last_action_tick) > limit)
|
||||
.filter(|g| {
|
||||
let idle_ticks = host.hub.ticks.saturating_sub(g.last_action_tick);
|
||||
let won_game = host.play.get(&g.conn).is_some_and(|play| play.won_game);
|
||||
idles_out(idle_ticks, minutes, won_game)
|
||||
})
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in idle {
|
||||
@@ -1692,9 +1755,17 @@ impl Game<'_> {
|
||||
/// bound to the overworld, whichever level the last server tick left in
|
||||
/// place (the last one ticked, often the nether). Its commands run there
|
||||
/// and the level in place is put back afterwards.
|
||||
/// The source is the server's own: no entity, at the world spawn, its feedback heard
|
||||
/// (the console's and an RCON client's always is).
|
||||
fn as_console_in_overworld<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
|
||||
let back = console_level(self.dim).and_then(|dim| self.enter_command_level(dim));
|
||||
let source = super::commands::execute::Source {
|
||||
silent: false,
|
||||
..self.server_source()
|
||||
};
|
||||
let outer = self.command_source.replace(source);
|
||||
let out = self.as_console(f);
|
||||
self.command_source = outer;
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
@@ -1912,12 +1983,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
|
||||
let added = state.lists.lock().unwrap().bans.add(BanEntry::new(
|
||||
user.clone(),
|
||||
Some("Server".into()),
|
||||
None,
|
||||
reason.clone(),
|
||||
));
|
||||
let added = state.lists.lock().unwrap().ban(&user, reason.clone());
|
||||
if added {
|
||||
self.console_reply(Text::translate(
|
||||
"commands.ban.success",
|
||||
@@ -1957,12 +2023,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
|
||||
let added = state.lists.lock().unwrap().ip_bans.add(BanEntry::new(
|
||||
ip.clone(),
|
||||
Some("Server".into()),
|
||||
None,
|
||||
reason.clone(),
|
||||
));
|
||||
let added = state.lists.lock().unwrap().ban_ip(&ip, reason.clone());
|
||||
if !added {
|
||||
self.console_reply(Text::translate("commands.banip.failed", &[]));
|
||||
return;
|
||||
@@ -2580,6 +2641,23 @@ mod tests {
|
||||
assert!(parse_whitelist_bypass("not-a-uuid-at-all").is_err());
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.tick: a player idle past `player-idle-timeout` minutes is kicked,
|
||||
/// unless the End poem is showing (`ServerPlayer.wonGame`), which sends no action.
|
||||
#[test]
|
||||
fn a_player_watching_the_end_credits_is_not_kicked_for_idling() {
|
||||
use super::idles_out;
|
||||
// 5 minutes: 6000 ticks. One more is over the limit.
|
||||
assert!(!idles_out(6000, 5, false));
|
||||
assert!(idles_out(6001, 5, false));
|
||||
assert!(!idles_out(6001, 5, true));
|
||||
assert!(!idles_out(1_000_000, 5, true));
|
||||
// The credits are over (won_game false again): the same idle time kicks.
|
||||
assert!(idles_out(1_000_000, 5, false));
|
||||
// 0 or less: no timeout.
|
||||
assert!(!idles_out(1_000_000, 0, false));
|
||||
assert!(!idles_out(1_000_000, -3, false));
|
||||
}
|
||||
|
||||
/// `SaveAllCommand.saveAll`: a save that throws is the `ERROR_FAILED` the
|
||||
/// source is told about, not a log line the caller never sees.
|
||||
#[test]
|
||||
@@ -2604,6 +2682,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// MinecraftServer.tickServer: a tick only counts toward pause-when-empty with no player and
|
||||
/// no `/tick sprint` running (`ServerTickRateManager.isSprinting`); a sprint on an empty server
|
||||
/// keeps the count at 0, so it is never paused, and the count starts again after it.
|
||||
#[test]
|
||||
fn a_sprint_on_an_empty_server_does_not_count_toward_the_pause() {
|
||||
let pause_after = 60 * 20;
|
||||
let mut empty_ticks = 0;
|
||||
// One server tick: (players, a `/pregen` task, a `/tick sprint`) -> paused.
|
||||
let tick = |empty_ticks: &mut i32, players, pregen, sprinting| {
|
||||
super::count_empty_tick(empty_ticks, pause_after, players, pregen, sprinting)
|
||||
};
|
||||
for _ in 0..2000 {
|
||||
assert!(!tick(&mut empty_ticks, 0, false, true));
|
||||
}
|
||||
assert_eq!(empty_ticks, 0);
|
||||
// The sprint ended: 1200 empty ticks later the server pauses, not before.
|
||||
for n in 1..=pause_after {
|
||||
assert_eq!(
|
||||
tick(&mut empty_ticks, 0, false, false),
|
||||
n == pause_after,
|
||||
"tick {n}"
|
||||
);
|
||||
}
|
||||
// A sprint that starts while paused resumes the server at once.
|
||||
assert!(!tick(&mut empty_ticks, 0, false, true));
|
||||
assert_eq!(empty_ticks, 0);
|
||||
// A player, or a `/pregen` task, resets the count too.
|
||||
empty_ticks = 5;
|
||||
assert!(!tick(&mut empty_ticks, 1, false, false));
|
||||
assert_eq!(empty_ticks, 0);
|
||||
empty_ticks = 5;
|
||||
assert!(!tick(&mut empty_ticks, 0, true, false));
|
||||
assert_eq!(empty_ticks, 0);
|
||||
// pause-when-empty-seconds=0 never pauses and leaves the count alone.
|
||||
empty_ticks = 5;
|
||||
assert!(!super::count_empty_tick(
|
||||
&mut empty_ticks,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
false
|
||||
));
|
||||
assert_eq!(empty_ticks, 5);
|
||||
}
|
||||
|
||||
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
|
||||
/// named as unknown, so the crash line is never empty.
|
||||
#[test]
|
||||
@@ -2713,6 +2836,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BanPlayerCommands.banPlayers` skips a player who `isBanned`, and `BanIpCommands.banIp` refuses an
|
||||
/// address that is: the console's `/ban` and `/ban-ip` of one banned already change nothing (the first
|
||||
/// reason stays, and the console hears "Nothing changed"), whatever reason they give.
|
||||
#[test]
|
||||
fn the_console_does_not_ban_again_who_is_banned_already() {
|
||||
use super::{BanList, Lists, NameAndId};
|
||||
let dir = std::env::temp_dir().join(format!("horizon-console-ban-{}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let mut lists = Lists {
|
||||
ops: super::OpList::new(dir.join("ops.json")),
|
||||
white: super::WhiteList::new(dir.join("whitelist.json")),
|
||||
bans: BanList::new(dir.join("banned-players.json")),
|
||||
ip_bans: super::IpBanList::new(dir.join("banned-ips.json")),
|
||||
use_whitelist: false,
|
||||
enforce_whitelist: false,
|
||||
op_level: 4,
|
||||
player_idle_timeout: 0,
|
||||
stop: false,
|
||||
autosave: true,
|
||||
whitelist_bypass: Vec::new(),
|
||||
};
|
||||
let alpha = NameAndId {
|
||||
id: 0x5ca1ab1e,
|
||||
name: "Alpha".into(),
|
||||
};
|
||||
assert!(lists.ban(&alpha, Some("cheating".into())));
|
||||
assert!(!lists.ban(&alpha, Some("again".into())));
|
||||
assert!(!lists.ban(&alpha, None));
|
||||
let reason = lists.bans.get(&alpha).and_then(|b| b.reason.clone());
|
||||
assert_eq!(reason.as_deref(), Some("cheating"));
|
||||
assert!(lists.ban_ip("1.2.3.4", Some("spam".into())));
|
||||
assert!(!lists.ban_ip("1.2.3.4", Some("more spam".into())));
|
||||
let reason = lists
|
||||
.ip_bans
|
||||
.get(&"1.2.3.4".to_owned())
|
||||
.and_then(|b| b.reason.clone());
|
||||
assert_eq!(reason.as_deref(), Some("spam"));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A temporary ban (`/tempban`, the chat filter's) keeps the player out and says when it ends;
|
||||
/// once it has ended, or after `/pardon`, they come in.
|
||||
#[test]
|
||||
@@ -2796,3 +2959,85 @@ mod tests {
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod console_source_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Runs `command` as the console does and returns whether it worked and every message it printed.
|
||||
fn console(game: &mut Game<'_>, command: &str) -> (bool, String) {
|
||||
game.chat_capture = Some(Vec::new());
|
||||
let ok = game.as_console_in_overworld(|g| g.run_command(command));
|
||||
let said = game.chat_capture.take().unwrap_or_default();
|
||||
let said = said
|
||||
.iter()
|
||||
.map(|t| t.component().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(ok, said)
|
||||
}
|
||||
|
||||
/// `MinecraftServer.createCommandSourceStack`: the console's source has no entity and is named "Server"
|
||||
/// (an RCON client's, `RconConsoleSource`, "Rcon"): `/me` says so, and `teammsg`, which needs an entity,
|
||||
/// refuses it (`permissions.requires.entity`) instead of running as the player in place.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn the_console_runs_as_the_server_not_the_player_in_place() {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
let (ok, said) = console(&mut game, "me waves");
|
||||
assert!(
|
||||
ok && said.contains("chat.type.emote") && said.contains("\"Server\""),
|
||||
"{said}"
|
||||
);
|
||||
game.rcon_source = true;
|
||||
let (ok, said) = console(&mut game, "me waves");
|
||||
assert!(ok && said.contains("\"Rcon\""), "{said}");
|
||||
game.rcon_source = false;
|
||||
let (ok, said) = console(&mut game, "teammsg hi");
|
||||
assert!(
|
||||
!ok && said.contains("permissions.requires.entity"),
|
||||
"{said}"
|
||||
);
|
||||
// The console's source is put back after each line.
|
||||
assert!(game.command_source.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
//! A partial check of the world's predicate graph before a dedicated server is Ready.
|
||||
//!
|
||||
//! Vanilla refuses to start a world whose packs' predicates reference a predicate no pack defines
|
||||
//! (`MappedRegistry.freeze`: "Unbound values in registry"), or reference each other in a cycle
|
||||
//! (`MappedRegistry.freeze`: "Unbound values in registry"), name a predicate tag no pack defines
|
||||
//! ("Unbound tags in registry"), or reference each other in a cycle
|
||||
//! (`ReloadableServerRegistries.validateLootRegistries`: a reference "is recursively called").
|
||||
//!
|
||||
//! This is new, partial validation, not a port of `RegistryDataLoader` or `ValidationContext`. It
|
||||
//! follows a predicate's holders only where the condition codecs hold one: `all_of` and `any_of`
|
||||
//! `terms`, and `inverted` `term`. A holder is an identifier (a reference) or an inline condition,
|
||||
//! which is followed in the same way. Every other condition type and field, an identifier that does
|
||||
//! which is followed in the same way. `terms` is a `HolderSetCodec`: a list of holders, one holder
|
||||
//! alone (`compactListCodec`, at `.terms[0]`), or a `#tag`. A tag must be defined; the entries of a
|
||||
//! defined tag are not followed. Every other condition type and field, an identifier that does
|
||||
//! not parse, and the holders of loot tables, advancements and item modifiers are not checked.
|
||||
use horizon_client_core::identifier::parse_identifier;
|
||||
use serde_json::Value;
|
||||
@@ -20,6 +23,8 @@ const REGISTRY: &str = "minecraft:predicate";
|
||||
pub(super) enum GraphError {
|
||||
/// Referenced ids that no predicate binds.
|
||||
Unbound(BTreeSet<String>),
|
||||
/// Predicate tags that a `terms` names and no pack defines.
|
||||
UnboundTags(BTreeSet<String>),
|
||||
/// References that re-enter a predicate while it is being validated.
|
||||
Recursive(Vec<Recursion>),
|
||||
}
|
||||
@@ -39,6 +44,10 @@ impl GraphError {
|
||||
"Unbound values in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
|
||||
ids.iter().cloned().collect::<Vec<_>>().join(", ")
|
||||
)],
|
||||
GraphError::UnboundTags(ids) => vec![format!(
|
||||
"Unbound tags in registry ResourceKey[minecraft:root / {REGISTRY}]: [{}]",
|
||||
ids.iter().cloned().collect::<Vec<_>>().join(", ")
|
||||
)],
|
||||
GraphError::Recursive(found) => found
|
||||
.iter()
|
||||
.map(|r| {
|
||||
@@ -62,14 +71,16 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
|
||||
return Vec::new();
|
||||
};
|
||||
match ty.as_str() {
|
||||
"minecraft:all_of" | "minecraft:any_of" => condition
|
||||
.get("terms")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.map(|(i, term)| (format!(".terms[{i}]"), term))
|
||||
.collect(),
|
||||
"minecraft:all_of" | "minecraft:any_of" => match condition.get("terms") {
|
||||
Some(Value::Array(terms)) => terms
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, term)| (format!(".terms[{i}]"), term))
|
||||
.collect(),
|
||||
Some(Value::String(tag)) if tag.starts_with('#') => Vec::new(),
|
||||
Some(term) => vec![(".terms[0]".to_owned(), term)],
|
||||
None => Vec::new(),
|
||||
},
|
||||
"minecraft:inverted" => condition
|
||||
.get("term")
|
||||
.map(|term| vec![(".term".to_owned(), term)])
|
||||
@@ -78,12 +89,22 @@ fn holders(condition: &Value) -> Vec<(String, &Value)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every reference a condition holds, inline conditions included.
|
||||
fn references(condition: &Value, out: &mut Vec<String>) {
|
||||
/// The predicate tag an `all_of` or `any_of` names as its `terms` (`#ns:path`).
|
||||
fn tag(condition: &Value) -> Option<String> {
|
||||
let ty = parse_identifier(condition.get("type")?.as_str()?)?;
|
||||
if !matches!(ty.as_str(), "minecraft:all_of" | "minecraft:any_of") {
|
||||
return None;
|
||||
}
|
||||
parse_identifier(condition.get("terms")?.as_str()?.strip_prefix('#')?)
|
||||
}
|
||||
|
||||
/// Every reference and tag a condition holds, inline conditions included.
|
||||
fn references(condition: &Value, out: &mut Vec<String>, tags: &mut Vec<String>) {
|
||||
tags.extend(tag(condition));
|
||||
for (_, holder) in holders(condition) {
|
||||
match holder {
|
||||
Value::String(name) => out.extend(parse_identifier(name)),
|
||||
Value::Object(_) => references(holder, out),
|
||||
Value::Object(_) => references(holder, out, tags),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -125,12 +146,17 @@ fn walk<'v>(
|
||||
}
|
||||
}
|
||||
|
||||
/// The world's predicates (by normalized id, after pack precedence): missing references first, as
|
||||
/// the registry freezes before anything is validated, then recursive ones.
|
||||
pub(super) fn check(predicates: &BTreeMap<String, Value>) -> Result<(), GraphError> {
|
||||
/// The world's predicates (by normalized id, after pack precedence) and whether a pack defines a
|
||||
/// predicate tag: missing references first, then missing tags, as the registry freezes before
|
||||
/// anything is validated, then recursive references.
|
||||
pub(super) fn check(
|
||||
predicates: &BTreeMap<String, Value>,
|
||||
has_tag: impl Fn(&str) -> bool,
|
||||
) -> Result<(), GraphError> {
|
||||
let mut referenced = Vec::new();
|
||||
let mut tags = Vec::new();
|
||||
for condition in predicates.values() {
|
||||
references(condition, &mut referenced);
|
||||
references(condition, &mut referenced, &mut tags);
|
||||
}
|
||||
let unbound: BTreeSet<String> = referenced
|
||||
.into_iter()
|
||||
@@ -139,6 +165,10 @@ pub(super) fn check(predicates: &BTreeMap<String, Value>) -> Result<(), GraphErr
|
||||
if !unbound.is_empty() {
|
||||
return Err(GraphError::Unbound(unbound));
|
||||
}
|
||||
let unbound: BTreeSet<String> = tags.into_iter().filter(|id| !has_tag(id)).collect();
|
||||
if !unbound.is_empty() {
|
||||
return Err(GraphError::UnboundTags(unbound));
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
for (id, condition) in predicates {
|
||||
let mut stack = vec![id.as_str()];
|
||||
@@ -193,7 +223,7 @@ mod tests {
|
||||
// An identifier that does not parse is not checked here.
|
||||
("a:v", json!({"type": "inverted", "term": "Not An Id"})),
|
||||
]);
|
||||
assert_eq!(check(&predicates), Ok(()));
|
||||
assert_eq!(check(&predicates, |_| false), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -205,7 +235,7 @@ mod tests {
|
||||
),
|
||||
("a:c", json!({"type": "inverted", "term": "a:c"})),
|
||||
]);
|
||||
let error = check(&predicates).unwrap_err();
|
||||
let error = check(&predicates, |_| false).unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
GraphError::Unbound(["a:gone".to_owned(), "b:none".to_owned()].into())
|
||||
@@ -234,7 +264,7 @@ mod tests {
|
||||
json!({"type": "any_of", "terms": [{"type": "inverted", "term": "prg:self"}]}),
|
||||
),
|
||||
]);
|
||||
let mut lines = check(&predicates).unwrap_err().lines();
|
||||
let mut lines = check(&predicates, |_| false).unwrap_err().lines();
|
||||
lines.sort();
|
||||
// The two cycle lines are the ones vanilla 26.3 logged for this pack.
|
||||
assert_eq!(
|
||||
@@ -246,4 +276,74 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_terms_holder_is_terms_0_and_a_tag_must_be_defined() {
|
||||
let target = json!({"type": "minecraft:all_of", "terms": []});
|
||||
// Vanilla 26.3 starts a world whose `terms` is one bound holder alone.
|
||||
let valid = graph(&[
|
||||
("prg:target", target.clone()),
|
||||
("prg:ref", json!({"type": "all_of", "terms": "prg:target"})),
|
||||
(
|
||||
"prg:inline",
|
||||
json!({"type": "any_of", "terms": {"type": "inverted", "term": "prg:target"}}),
|
||||
),
|
||||
(
|
||||
"prg:tagged",
|
||||
json!({"type": "any_of", "terms": "#prg:some"}),
|
||||
),
|
||||
]);
|
||||
assert_eq!(check(&valid, |tag| tag == "prg:some"), Ok(()));
|
||||
let missing = graph(&[
|
||||
("prg:target", target.clone()),
|
||||
(
|
||||
"prg:ref",
|
||||
json!({"type": "minecraft:all_of", "terms": "prg:missing"}),
|
||||
),
|
||||
(
|
||||
"prg:tagged",
|
||||
json!({"type": "all_of", "terms": "#prg:none"}),
|
||||
),
|
||||
]);
|
||||
// Missing values first, then missing tags: the lines vanilla 26.3 logged for each.
|
||||
assert_eq!(
|
||||
check(&missing, |_| false).unwrap_err().lines(),
|
||||
[
|
||||
"Unbound values in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:missing]"
|
||||
]
|
||||
);
|
||||
let tags = graph(&[
|
||||
("prg:target", target.clone()),
|
||||
(
|
||||
"prg:ref",
|
||||
json!({"type": "minecraft:all_of", "terms": "#prg:none"}),
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
check(&tags, |_| false).unwrap_err().lines(),
|
||||
[
|
||||
"Unbound tags in registry ResourceKey[minecraft:root / minecraft:predicate]: [prg:none]"
|
||||
]
|
||||
);
|
||||
let cycle = graph(&[
|
||||
("prg:target", target),
|
||||
(
|
||||
"prg:ref",
|
||||
json!({"type": "minecraft:any_of", "terms": "prg:loop"}),
|
||||
),
|
||||
(
|
||||
"prg:loop",
|
||||
json!({"type": "minecraft:inverted", "term": "prg:ref"}),
|
||||
),
|
||||
]);
|
||||
let mut lines = check(&cycle, |_| false).unwrap_err().lines();
|
||||
lines.sort();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
"Found loot table element validation problem in {prg:loop@minecraft:predicate}.term->{prg:ref@minecraft:predicate}.terms[0]: prg:loop of type minecraft:predicate is recursively called",
|
||||
"Found loot table element validation problem in {prg:ref@minecraft:predicate}.terms[0]->{prg:loop@minecraft:predicate}.term: prg:ref of type minecraft:predicate is recursively called",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -982,6 +982,7 @@ impl Game<'_> {
|
||||
items: [stack],
|
||||
ticks: 0,
|
||||
playing: false,
|
||||
loaded_clock: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1442,6 +1443,9 @@ impl Game<'_> {
|
||||
ProjectileKind::Egg => "minecraft:egg",
|
||||
ProjectileKind::SmallFireball => "minecraft:small_fireball",
|
||||
ProjectileKind::WindCharge => "minecraft:wind_charge",
|
||||
ProjectileKind::Potion(_, true) => "minecraft:lingering_potion",
|
||||
ProjectileKind::Potion(..) => "minecraft:splash_potion",
|
||||
ProjectileKind::ExperienceBottle => "minecraft:experience_bottle",
|
||||
_ => "minecraft:unknown_projectile",
|
||||
};
|
||||
let (p, v) = (shot.position, shot.velocity);
|
||||
|
||||
@@ -12,7 +12,6 @@ use horizon_client::{
|
||||
mobs::{Mob, Species, sulfur_cube},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
random_source,
|
||||
};
|
||||
// Its old home, for mixin code that names it here.
|
||||
pub(super) use horizon_client::mod_api::NativeDispensed;
|
||||
@@ -1198,9 +1197,9 @@ impl Game<'_> {
|
||||
"minecraft:experience_bottle"
|
||||
| "minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut random_source::entity(), dir, power, uncertainty);
|
||||
self.launch_thrown(one, at, shot.velocity, None);
|
||||
let mut shot = super::potions::thrown_projectile(one, at, false);
|
||||
shot.shoot_own(dir, power, uncertainty);
|
||||
self.add_projectile(shot);
|
||||
}
|
||||
_ => {
|
||||
let kind = match item.as_str() {
|
||||
@@ -1267,14 +1266,18 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
let (_, uuid) = horizon_client::mobs::living_random();
|
||||
self.persist.uuids.insert(self.next_entity, uuid);
|
||||
let mut stand =
|
||||
let stand =
|
||||
horizon_client::entities::ArmorStand::new(self.next_entity, foot, y_rot(facing));
|
||||
// Entity.applyImplicitComponents: a name from an anvil names the stand.
|
||||
if let Some(name) = stack.custom_name() {
|
||||
stand.custom_name = Some(horizon_client::text::Text::literal(name));
|
||||
}
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
self.armor_stands.push(stand);
|
||||
// EntityType.appendDefaultStackConfig: the dispenser's yaw first, then
|
||||
// the item's createDefaultStackConfig with no user.
|
||||
self.apply_item_stack_config(
|
||||
super::commands::Target::Stand(self.next_entity),
|
||||
"minecraft:armor_stand",
|
||||
&stack,
|
||||
false,
|
||||
);
|
||||
(shrink(stack), CLICK)
|
||||
}
|
||||
/// A tame donkey, mule or llama in front without a chest gets it.
|
||||
@@ -1343,7 +1346,7 @@ impl Game<'_> {
|
||||
self.boats.push(boat);
|
||||
// BoatDispenseItemBehavior: createDefaultStackConfig with no user,
|
||||
// then setYRot(direction.toYRot()).
|
||||
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, &stack, false);
|
||||
self.apply_item_stack_config(super::commands::Target::Boat(id), &kind, &stack, false);
|
||||
self.set_boat_yaw(id, yaw);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let boat = self.boats.last().unwrap();
|
||||
@@ -1393,12 +1396,7 @@ impl Game<'_> {
|
||||
self.carts.push(cart);
|
||||
// AbstractMinecart.createMinecart: createDefaultStackConfig with no user.
|
||||
let id = self.next_entity;
|
||||
self.apply_vehicle_stack_config(
|
||||
super::commands::Target::Cart(id),
|
||||
kind.id(),
|
||||
&stack,
|
||||
false,
|
||||
);
|
||||
self.apply_item_stack_config(super::commands::Target::Cart(id), kind.id(), &stack, false);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
|
||||
@@ -191,22 +191,12 @@ pub(super) struct Crystal {
|
||||
pub beam: Option<[i32; 3]>,
|
||||
/// `setPermanentlyInvulnerable` (respawned spike crystals).
|
||||
pub invulnerable: bool,
|
||||
/// `onGround` and `fallDistance`: a crystal never moves itself, so they stay
|
||||
/// as loaded until `/tp` grounds it or a cobweb or powder snow resets the
|
||||
/// fall (`crystal.rs`).
|
||||
pub fall: (bool, f64),
|
||||
}
|
||||
impl Crystal {
|
||||
/// The start of `time` (the spin and bob) for a crystal at `position`: the random draw
|
||||
/// vanilla makes, or under `HORIZON_STILL` one seeded from the position alone. Which crystal
|
||||
/// gets which draw, and a server's entity ids, follow the order chunks load in, which differs
|
||||
/// between launches; the position is the same in every copy of the world. The draw is made
|
||||
/// either way, so the random stays where it would be.
|
||||
pub(super) fn start_time(position: Vec3, drawn: i32, still: bool) -> i32 {
|
||||
if still {
|
||||
horizon_client::mobs::entity_random(0, position)
|
||||
.next_int_bound(100_000)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
drawn
|
||||
}
|
||||
}
|
||||
/// `EntityType.END_CRYSTAL`: 2 x 2.
|
||||
pub(super) fn bounds(&self) -> Aabb {
|
||||
let p = self.position;
|
||||
@@ -1291,14 +1281,8 @@ impl Game<'_> {
|
||||
let time = Crystal::start_time(at, drawn, self.clock.still());
|
||||
// EndCrystal's constructor takes an id from the entity counter.
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
show_bottom: true,
|
||||
time,
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
self.crystals
|
||||
.push(Crystal::new(self.next_entity, at, true, time));
|
||||
self.world
|
||||
.modified
|
||||
.insert((at.x.floor() as i32 >> 4, at.z.floor() as i32 >> 4));
|
||||
@@ -1328,6 +1312,7 @@ impl Game<'_> {
|
||||
.flatten();
|
||||
for i in 0..self.crystals.len() {
|
||||
self.crystals[i].time += 1;
|
||||
self.crystal_block_effects(i);
|
||||
let p = self.crystals[i].position;
|
||||
let b = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
if let Some(fire) = fire
|
||||
@@ -3666,7 +3651,7 @@ impl Crystal {
|
||||
self.position,
|
||||
Vec3::ZERO,
|
||||
[0.0, 0.0],
|
||||
(false, 0.0),
|
||||
self.fall,
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
@@ -3694,6 +3679,7 @@ impl Crystal {
|
||||
_ => None,
|
||||
},
|
||||
invulnerable: horizon_client::entity_nbt::flag(tag, "Invulnerable"),
|
||||
fall: horizon_client::entity_nbt::ground_from_nbt(tag),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3916,14 +3902,8 @@ impl Game<'_> {
|
||||
// EndCrystal's constructor takes an id from the entity counter, so
|
||||
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
show_bottom: false,
|
||||
time,
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
self.crystals
|
||||
.push(Crystal::new(self.next_entity, at, false, time));
|
||||
// EndCrystalItem.useOn: ENTITY_PLACE at the block above, by the player.
|
||||
self.post_block_game_event("minecraft:entity_place", above, self.sculk_actor(), None);
|
||||
self.world.modified.insert((above[0] >> 4, above[2] >> 4));
|
||||
@@ -4219,12 +4199,9 @@ impl Game<'_> {
|
||||
let drawn = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
let time = Crystal::start_time(at, drawn, self.clock.still());
|
||||
self.crystals.push(Crystal {
|
||||
id: 0,
|
||||
position: at,
|
||||
show_bottom: true,
|
||||
time,
|
||||
beam: Some([0, 128, 0]),
|
||||
invulnerable: true,
|
||||
..Crystal::new(0, at, true, time)
|
||||
});
|
||||
let _ = self.set_state_named([cx, h, cz], "minecraft:bedrock", &[]);
|
||||
let _ = self.set_state_named([cx, h + 1, cz], "minecraft:fire", &[]);
|
||||
@@ -4769,6 +4746,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
mod crystal;
|
||||
mod death_render;
|
||||
use death_render::{dissolve_color, dissolve_mask, dragon_rays};
|
||||
|
||||
@@ -5733,6 +5711,7 @@ mod dragon_fireball_spawn_tests {
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! An end crystal's construction and the blocks its tick reads.
|
||||
use super::*;
|
||||
|
||||
impl Crystal {
|
||||
/// `new EndCrystal(level, x, y, z)`: no beam, not invulnerable, on no
|
||||
/// ground with no fall (what `Entity`'s constructor leaves).
|
||||
pub(in crate::game) fn new(id: u32, position: Vec3, show_bottom: bool, time: i32) -> Self {
|
||||
Self {
|
||||
id,
|
||||
position,
|
||||
show_bottom,
|
||||
time,
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
fall: (false, 0.0),
|
||||
}
|
||||
}
|
||||
/// The start of `time` (the spin and bob) for a crystal at `position`: the random draw
|
||||
/// vanilla makes, or under `HORIZON_STILL` one seeded from the position alone. Which crystal
|
||||
/// gets which draw, and a server's entity ids, follow the order chunks load in, which differs
|
||||
/// between launches; the position is the same in every copy of the world. The draw is made
|
||||
/// either way, so the random stays where it would be.
|
||||
pub(in crate::game) fn start_time(position: Vec3, drawn: i32, still: bool) -> i32 {
|
||||
if still {
|
||||
horizon_client::mobs::entity_random(0, position)
|
||||
.next_int_bound(100_000)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
drawn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `EndCrystal.tick`'s `applyEffectsFromBlocks` for crystal `i`, which never
|
||||
/// moves: a cobweb or powder snow in its box (2 x 2 x 2, deflated by
|
||||
/// `1.0E-5F`) calls `makeStuckInBlock`, which resets its fall distance.
|
||||
/// Powder snow's inside shape for a fall over 2.5 is only 0.9 tall
|
||||
/// (`FALLING_COLLISION_SHAPE`). A bubble column's reset and `stepOn` for a
|
||||
/// grounded crystal are not ported.
|
||||
pub(super) fn crystal_block_effects(&mut self, i: usize) {
|
||||
let (p, fall) = (self.crystals[i].position, self.crystals[i].fall.1);
|
||||
let e = f64::from(1.0e-5_f32);
|
||||
let lo = [p.x - 1.0 + e, p.y + e, p.z - 1.0 + e].map(|v| v.floor() as i32);
|
||||
let hi = [p.x + 1.0 - e, p.y + 2.0 - e, p.z + 1.0 - e].map(|v| v.floor() as i32);
|
||||
let facts = self.res.facts();
|
||||
let stuck = (lo[0]..=hi[0]).any(|x| {
|
||||
(lo[1]..=hi[1]).any(|y| {
|
||||
(lo[2]..=hi[2]).any(|z| match &*facts.get(self.world.block(x, y, z)).name {
|
||||
"minecraft:cobweb" => true,
|
||||
"minecraft:powder_snow" => fall <= 2.5 || p.y + e < f64::from(y) + 0.9,
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
});
|
||||
if stuck {
|
||||
self.crystals[i].fall.1 = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ fn game() -> Game<'static> {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
|
||||
game
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ fn items_past_the_simulation_distance_enter_no_end_gateway() {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
|
||||
game.load_ticking_neighbourhood(cx, 0);
|
||||
game.world.set(x, 64, 8, gateway).unwrap();
|
||||
game.dropped
|
||||
.push(item(id, Vec3::new(f64::from(x) + 0.5, 64.2, 8.5)));
|
||||
|
||||
@@ -36,6 +36,7 @@ fn a_mob_presses_a_plate_only_inside_the_simulation_distance() {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
|
||||
game.load_ticking_neighbourhood(cx, 0);
|
||||
game.world.set(x, 63, 8, stone).unwrap();
|
||||
game.world.set(x, 64, 8, plate).unwrap();
|
||||
game.mobs.push(Mob::new(
|
||||
|
||||
@@ -862,13 +862,36 @@ impl Game<'_> {
|
||||
let (mut items_kept, mut orbs_kept) = (0, 0);
|
||||
let (mut boats_gone, mut carts_gone) = (0, 0);
|
||||
let mut destroyed_contents = Vec::new();
|
||||
// The entities in reach, less those a mod spares (`native_explosion_entities`); no list
|
||||
// without native mods, which are the only ones to spare any.
|
||||
let spared: std::collections::HashSet<u32> = if horizon_client::mod_api::mods().is_empty() {
|
||||
Default::default()
|
||||
} else {
|
||||
let reach: Vec<u32> = self
|
||||
.entity_handles()
|
||||
.filter_map(|handle| {
|
||||
with_entity!(self, handle, e => {
|
||||
let feet = e.position();
|
||||
let dist = blast_distance(center, [feet.x, feet.y, feet.z]);
|
||||
(dist <= diameter).then(|| e.id())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut kept = reach.clone();
|
||||
self.native_explosion_entities(self.dim.id(), center, &mut kept);
|
||||
let kept: std::collections::HashSet<u32> = kept.into_iter().collect();
|
||||
reach.into_iter().filter(|id| !kept.contains(id)).collect()
|
||||
};
|
||||
for handle in self.entity_handles() {
|
||||
let handle = match handle {
|
||||
EntityHandle::Boat(i) => EntityHandle::Boat(i - boats_gone),
|
||||
EntityHandle::Cart(i) => EntityHandle::Cart(i - carts_gone),
|
||||
other => other,
|
||||
};
|
||||
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast));
|
||||
let spare =
|
||||
!spared.is_empty() && spared.contains(&with_entity!(self, handle, e => e.id()));
|
||||
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast))
|
||||
.filter(|_| !spare);
|
||||
let mut destroyed = false;
|
||||
if let Some(Hit {
|
||||
dist,
|
||||
@@ -1828,6 +1851,55 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// Spares one entity from every blast (`native_explosion_entities`).
|
||||
struct Spare(u32);
|
||||
impl crate::mod_api::Hooks for Spare {
|
||||
fn explosion_entities(
|
||||
&self,
|
||||
_: &mut dyn crate::mod_api::Host,
|
||||
event: &mut crate::mod_api::ExplosionEntitiesEvent<'_>,
|
||||
) {
|
||||
event.entities.retain(|&id| id != self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mod_spares_an_entity_from_a_blast() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::explosion::tests::a_mod_spares_an_entity_from_a_blast",
|
||||
a_mod_spares_an_entity_from_a_blast_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
/// Two husks either side of a blast: the one a mod takes out of the list is neither hurt nor
|
||||
/// pushed, the other is both.
|
||||
fn a_mod_spares_an_entity_from_a_blast_in_own_process() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
let spare: Box<dyn crate::mod_api::Hooks> = Box::new(Spare(2));
|
||||
assert!(crate::mod_api::MODS.set(vec![spare].into()).is_ok());
|
||||
let kind = game.res.mob_types().get("minecraft:husk").unwrap().clone();
|
||||
for (id, x) in [(1, 6.5), (2, 10.5)] {
|
||||
let husk =
|
||||
horizon_client::mobs::Mob::new(id, kind.clone(), Vec3::new(x, 64.0, 8.5), 0.0);
|
||||
game.mobs.push(husk);
|
||||
}
|
||||
let full = game.mobs[0].health;
|
||||
game.server_explode_from_cause(
|
||||
[8.5, 64.0, 8.5],
|
||||
2.0,
|
||||
BlockInteraction::Keep,
|
||||
false,
|
||||
ExplosionSource::default().with_silence(true),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (hit, spared) = (&game.mobs[0], &game.mobs[1]);
|
||||
assert!(hit.health < full && hit.velocity != Vec3::ZERO);
|
||||
assert_eq!((spared.health, spared.velocity), (full, Vec3::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mob_that_refuses_the_hit_is_still_pushed() {
|
||||
use horizon_client::mobs::{Damage, DamageKind, Mob, Species};
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
//! Each hook does nothing while its extra is off and no mod uses it.
|
||||
//!
|
||||
//! Each Horizon extra's game glue is a child module here, so it leaves the engine in one move
|
||||
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options).
|
||||
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options),
|
||||
//! `mouse_tweaks` and `sorting` (the inventory extras).
|
||||
pub(in crate::game) mod mouse_tweaks;
|
||||
pub(in crate::game) mod pvp;
|
||||
mod reconnect;
|
||||
mod sorting;
|
||||
|
||||
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
|
||||
use horizon_client::{
|
||||
|
||||
+16
-163
@@ -17,26 +17,12 @@
|
||||
//! other side. Down over a result slot takes one craft or the whole furnace
|
||||
//! output to the inventory; nothing ever goes into a result slot.
|
||||
//!
|
||||
//! Sorting draws a small button by a menu's storage slots and one by the
|
||||
//! inventory's (an optional key sorts the part under the cursor); the
|
||||
//! clicks come from `inv_sort::plan`.
|
||||
use super::{Creative, Game, Screen};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
inv_sort,
|
||||
items::ItemStack,
|
||||
keybinds,
|
||||
menu::Input,
|
||||
stations::hover_name,
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// A sort button's side in GUI pixels.
|
||||
const SORT_BUTTON: f32 = 9.0;
|
||||
//! Sorting, which clicks through `tweak_click` too, is in `sorting.rs`.
|
||||
use crate::game::{Creative, Game, Screen};
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, menu::Input};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Tweaks {
|
||||
pub(in crate::game) struct Tweaks {
|
||||
drag: Option<Drag>,
|
||||
/// Wheel steps not yet acted on (a trackpad scrolls in fractions).
|
||||
wheel: f32,
|
||||
@@ -44,7 +30,7 @@ pub(super) struct Tweaks {
|
||||
/// A server's menu the tweaks run in: their clicks are vanilla's in these,
|
||||
/// and the menu engine knows which slot takes what. Not the menus whose slots
|
||||
/// cost or trigger something (anvils, enchanting, merchants, beacons, crafters).
|
||||
pub(super) fn tweak_menu(name: &str) -> bool {
|
||||
pub(in crate::game) fn tweak_menu(name: &str) -> bool {
|
||||
name == "inventory"
|
||||
|| name.starts_with("minecraft:generic_9x")
|
||||
|| matches!(
|
||||
@@ -72,7 +58,7 @@ struct Drag {
|
||||
|
||||
impl Game<'_> {
|
||||
/// The open screen is one the tweaks run in, with the extra on.
|
||||
pub(super) fn tweaks_on(&self) -> bool {
|
||||
pub(in crate::game) fn tweaks_on(&self) -> bool {
|
||||
if !self.settings.extras.mouse_tweaks
|
||||
|| self.screen != Screen::Inventory
|
||||
|| self.station.is_some()
|
||||
@@ -87,7 +73,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// One ordinary click on a game slot. False when nothing took it.
|
||||
pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
|
||||
pub(in crate::game) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
|
||||
if self.remote.is_some() {
|
||||
return self.remote_menu_ops(&[(Some(slot), button, input)]);
|
||||
}
|
||||
@@ -123,7 +109,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// A press on `slot`: true when the tweaks took it (a right-drag
|
||||
/// placing), else vanilla's press runs and the tweaks watch the drag.
|
||||
pub(super) fn tweak_press(&mut self, slot: Option<usize>, right: bool, shift: bool) -> bool {
|
||||
pub(in crate::game) fn tweak_press(
|
||||
&mut self,
|
||||
slot: Option<usize>,
|
||||
right: bool,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
self.tweaks.drag = None;
|
||||
let Some(slot) = slot.filter(|&s| s != 200 && s < Creative::DISPLAY) else {
|
||||
return false;
|
||||
@@ -174,7 +165,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The cursor moved while a button is held: true when the tweaks own
|
||||
/// the drag (vanilla's quick craft drag doesn't grow).
|
||||
pub(super) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
|
||||
pub(in crate::game) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
|
||||
let Some(mut drag) = self.tweaks.drag.take() else {
|
||||
return false;
|
||||
};
|
||||
@@ -222,11 +213,11 @@ impl Game<'_> {
|
||||
owned
|
||||
}
|
||||
/// The button went up: the drag ends.
|
||||
pub(super) fn tweak_release(&mut self) {
|
||||
pub(in crate::game) fn tweak_release(&mut self) {
|
||||
self.tweaks.drag = None;
|
||||
}
|
||||
/// The wheel over an inventory screen: true when the tweaks used it.
|
||||
pub(super) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
|
||||
pub(in crate::game) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
|
||||
if !self.settings.extras.mouse_tweaks_wheel || !self.tweaks_on() || self.carried.is_some() {
|
||||
return false;
|
||||
}
|
||||
@@ -356,144 +347,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The sort buttons of the open screen, with the extra on: their
|
||||
/// corner (from the screen's) and the slots each sorts. Any menu screen
|
||||
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
|
||||
/// right of its storage slots (a chest's title line) when it has some,
|
||||
/// one by the main inventory's (the "Inventory" label's line).
|
||||
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
|
||||
use super::NativeSlotRole as Role;
|
||||
if !self.settings.extras.sort {
|
||||
return Vec::new();
|
||||
}
|
||||
let roles = self.native_slot_roles();
|
||||
let of = |role| -> Vec<usize> {
|
||||
roles
|
||||
.iter()
|
||||
.filter(|(_, r)| *r == role)
|
||||
.map(|(s, _)| *s)
|
||||
.collect()
|
||||
};
|
||||
// Over the section's last column: (160, 5) over a chest's slots.
|
||||
let right = |slots: &[usize]| {
|
||||
slots
|
||||
.iter()
|
||||
.map(|&s| self.slot_position(s).0 + 8.0)
|
||||
.fold(f32::MIN, f32::max)
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
let storage = of(Role::Storage);
|
||||
if !storage.is_empty() {
|
||||
let top = storage
|
||||
.iter()
|
||||
.map(|&s| self.slot_position(s).1 - 13.0)
|
||||
.fold(f32::MAX, f32::min);
|
||||
buttons.push(((right(&storage), top), storage));
|
||||
}
|
||||
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
|
||||
// above the first row): a mod's screen places its inventory itself.
|
||||
let main = of(Role::Inventory);
|
||||
if !main.is_empty() {
|
||||
let y = self
|
||||
.native_screen_inventory_y()
|
||||
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
|
||||
buttons.push(((right(&main), y), main));
|
||||
}
|
||||
buttons
|
||||
}
|
||||
/// The sort buttons: a bordered square with three bars, lighter under
|
||||
/// the cursor.
|
||||
pub(super) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
for ((x, y), _) in self.sort_buttons() {
|
||||
let (x, y) = (ox + x, oy + y);
|
||||
let (cx, cy) = self.cursor;
|
||||
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
|
||||
let face = if hovered {
|
||||
rgba(220, 220, 220, 255)
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
};
|
||||
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
SORT_BUTTON - 2.0,
|
||||
SORT_BUTTON - 2.0,
|
||||
face,
|
||||
);
|
||||
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
|
||||
let by = y + 2.0 + 2.0 * i as f32;
|
||||
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A left press on a sort button sorts its slots (true).
|
||||
pub(super) fn sort_press(&mut self) -> bool {
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
let (ox, oy) = self.inventory_origin(&batch);
|
||||
let (cx, cy) = self.cursor;
|
||||
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
|
||||
let (x, y) = (ox + x, oy + y);
|
||||
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
|
||||
});
|
||||
let Some((_, slots)) = hit else {
|
||||
return false;
|
||||
};
|
||||
self.sort_slots(&slots);
|
||||
true
|
||||
}
|
||||
/// The sort key: sorts the part under the cursor (the container when
|
||||
/// the cursor is over no slot).
|
||||
pub(super) fn sort_key_press(&mut self, key: KeyCode) -> bool {
|
||||
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
|
||||
}
|
||||
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
|
||||
self.sort_input(&keybinds::mouse_name(button))
|
||||
}
|
||||
fn sort_input(&mut self, name: &str) -> bool {
|
||||
let e = &self.settings.extras;
|
||||
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
|
||||
return false;
|
||||
}
|
||||
let buttons = self.sort_buttons();
|
||||
let hovered = self.inventory_target().0;
|
||||
let slots = buttons
|
||||
.iter()
|
||||
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
|
||||
.or(buttons.first())
|
||||
.map(|(_, slots)| slots.clone());
|
||||
let Some(slots) = slots else {
|
||||
return false;
|
||||
};
|
||||
self.sort_slots(&slots);
|
||||
true
|
||||
}
|
||||
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
|
||||
fn sort_slots(&mut self, slots: &[usize]) {
|
||||
if self.carried.is_some() {
|
||||
return;
|
||||
}
|
||||
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
|
||||
let clicks = {
|
||||
let rank = |s: &ItemStack| {
|
||||
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
|
||||
(order, hover_name(&self.pack.names, s))
|
||||
};
|
||||
let max = |s: &ItemStack| self.max_stack(s);
|
||||
inv_sort::plan(&views, &rank, &max)
|
||||
};
|
||||
for i in clicks {
|
||||
self.tweak_click(slots[i], 0, Input::Pickup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tweak_menu;
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Sorting (`sort`, `sortKey`; #332, `docs/extras.md`), off by default: a small button by a
|
||||
//! menu's storage slots and one by the inventory's (an optional key sorts the part under the
|
||||
//! cursor). The clicks come from `inv_sort::plan` and go through the Mouse Tweaks' ordinary slot
|
||||
//! clicks (`mouse_tweaks.rs`), so they work wherever vanilla's do.
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
inv_sort,
|
||||
items::ItemStack,
|
||||
keybinds,
|
||||
menu::Input,
|
||||
stations::hover_name,
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// A sort button's side in GUI pixels.
|
||||
const SORT_BUTTON: f32 = 9.0;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The sort buttons of the open screen, with the extra on: their
|
||||
/// corner (from the screen's) and the slots each sorts. Any menu screen
|
||||
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
|
||||
/// right of its storage slots (a chest's title line) when it has some,
|
||||
/// one by the main inventory's (the "Inventory" label's line).
|
||||
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
|
||||
use crate::game::NativeSlotRole as Role;
|
||||
if !self.settings.extras.sort {
|
||||
return Vec::new();
|
||||
}
|
||||
let roles = self.native_slot_roles();
|
||||
let of = |role| -> Vec<usize> {
|
||||
roles
|
||||
.iter()
|
||||
.filter(|(_, r)| *r == role)
|
||||
.map(|(s, _)| *s)
|
||||
.collect()
|
||||
};
|
||||
// Over the section's last column: (160, 5) over a chest's slots.
|
||||
let right = |slots: &[usize]| {
|
||||
slots
|
||||
.iter()
|
||||
.map(|&s| self.slot_position(s).0 + 8.0)
|
||||
.fold(f32::MIN, f32::max)
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
let storage = of(Role::Storage);
|
||||
if !storage.is_empty() {
|
||||
let top = storage
|
||||
.iter()
|
||||
.map(|&s| self.slot_position(s).1 - 13.0)
|
||||
.fold(f32::MAX, f32::min);
|
||||
buttons.push(((right(&storage), top), storage));
|
||||
}
|
||||
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
|
||||
// above the first row): a mod's screen places its inventory itself.
|
||||
let main = of(Role::Inventory);
|
||||
if !main.is_empty() {
|
||||
let y = self
|
||||
.native_screen_inventory_y()
|
||||
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
|
||||
buttons.push(((right(&main), y), main));
|
||||
}
|
||||
buttons
|
||||
}
|
||||
/// The sort buttons: a bordered square with three bars, lighter under
|
||||
/// the cursor.
|
||||
pub(in crate::game) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
let atlas = &self.gui.atlas;
|
||||
for ((x, y), _) in self.sort_buttons() {
|
||||
let (x, y) = (ox + x, oy + y);
|
||||
let (cx, cy) = self.cursor;
|
||||
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
|
||||
let face = if hovered {
|
||||
rgba(220, 220, 220, 255)
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
};
|
||||
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
SORT_BUTTON - 2.0,
|
||||
SORT_BUTTON - 2.0,
|
||||
face,
|
||||
);
|
||||
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
|
||||
let by = y + 2.0 + 2.0 * i as f32;
|
||||
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A left press on a sort button sorts its slots (true).
|
||||
pub(in crate::game) fn sort_press(&mut self) -> bool {
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new([r.config.width, r.config.height]);
|
||||
let (ox, oy) = self.inventory_origin(&batch);
|
||||
let (cx, cy) = self.cursor;
|
||||
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
|
||||
let (x, y) = (ox + x, oy + y);
|
||||
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
|
||||
});
|
||||
let Some((_, slots)) = hit else {
|
||||
return false;
|
||||
};
|
||||
self.sort_slots(&slots);
|
||||
true
|
||||
}
|
||||
/// The sort key: sorts the part under the cursor (the container when
|
||||
/// the cursor is over no slot).
|
||||
pub(in crate::game) fn sort_key_press(&mut self, key: KeyCode) -> bool {
|
||||
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
|
||||
}
|
||||
pub(in crate::game) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
|
||||
self.sort_input(&keybinds::mouse_name(button))
|
||||
}
|
||||
fn sort_input(&mut self, name: &str) -> bool {
|
||||
let e = &self.settings.extras;
|
||||
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
|
||||
return false;
|
||||
}
|
||||
let buttons = self.sort_buttons();
|
||||
let hovered = self.inventory_target().0;
|
||||
let slots = buttons
|
||||
.iter()
|
||||
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
|
||||
.or(buttons.first())
|
||||
.map(|(_, slots)| slots.clone());
|
||||
let Some(slots) = slots else {
|
||||
return false;
|
||||
};
|
||||
self.sort_slots(&slots);
|
||||
true
|
||||
}
|
||||
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
|
||||
fn sort_slots(&mut self, slots: &[usize]) {
|
||||
if self.carried.is_some() {
|
||||
return;
|
||||
}
|
||||
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
|
||||
let clicks = {
|
||||
let rank = |s: &ItemStack| {
|
||||
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
|
||||
(order, hover_name(&self.pack.names, s))
|
||||
};
|
||||
let max = |s: &ItemStack| self.max_stack(s);
|
||||
inv_sort::plan(&views, &rank, &max)
|
||||
};
|
||||
for i in clicks {
|
||||
self.tweak_click(slots[i], 0, Input::Pickup);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ fn loaded_air_is_discarded_on_first_actual_game_tick() {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.res.facts().is_air(game.world.block(4, 204, 4)));
|
||||
|
||||
@@ -67,6 +67,9 @@ fn game() -> Game<'static> {
|
||||
.add_empty_chunk(ChunkPos::new(x, 0))
|
||||
.unwrap();
|
||||
}
|
||||
for x in 0..3 {
|
||||
game.load_ticking_neighbourhood(x, 0);
|
||||
}
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(24.5, 201.0, 12.5)).unwrap());
|
||||
let net = NetConfig {
|
||||
announce: false,
|
||||
|
||||
@@ -273,6 +273,7 @@ mod item_collision_tests {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("summon minecart 4.5 200 4.5"));
|
||||
assert!(game.run_command("summon oak_boat 10.5 200 10.5"));
|
||||
|
||||
@@ -55,6 +55,9 @@ fn game() -> Game<'static> {
|
||||
.chunks
|
||||
.insert((x, 0), Arc::new(Chunk::empty(x, 0)));
|
||||
}
|
||||
for x in 0..2 {
|
||||
game.load_ticking_neighbourhood(x, 0);
|
||||
}
|
||||
assert!(game.run_command("fill 0 99 0 31 99 15 stone"));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(28.5, 100.0, 10.5)).unwrap());
|
||||
let net = NetConfig {
|
||||
|
||||
@@ -56,6 +56,9 @@ fn game() -> Game<'static> {
|
||||
.add_empty_chunk(ChunkPos::new(cx, 0))
|
||||
.unwrap();
|
||||
}
|
||||
for cx in 0..2 {
|
||||
game.load_ticking_neighbourhood(cx, 0);
|
||||
}
|
||||
for command in [
|
||||
"difficulty normal",
|
||||
"forceload add 0 0 31 15",
|
||||
|
||||
@@ -49,6 +49,7 @@ fn game() -> Game<'static> {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"fill 0 99 0 15 99 15 stone",
|
||||
|
||||
@@ -694,7 +694,7 @@ impl Game<'_> {
|
||||
position: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
) {
|
||||
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0);
|
||||
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0, 0);
|
||||
}
|
||||
|
||||
pub(super) fn add_item_entity(
|
||||
@@ -704,7 +704,21 @@ impl Game<'_> {
|
||||
velocity: Option<[f64; 3]>,
|
||||
pickup_delay: u32,
|
||||
) -> [f64; 3] {
|
||||
self.add_item_entity_drawn(stack, position, velocity, velocity.is_none(), pickup_delay)
|
||||
self.add_item_entity_drawn(
|
||||
stack,
|
||||
position,
|
||||
velocity,
|
||||
velocity.is_none(),
|
||||
pickup_delay,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// `spawnAtLocation` for a drop that then calls
|
||||
/// `ItemEntity.setExtendedLifetime`: the item joins the world at `age`
|
||||
/// instead of 0, so the wither's nether star lives 12000 ticks, not 6000.
|
||||
pub(super) fn spawn_item_aged(&mut self, stack: ItemStack, position: [f64; 3], age: i32) {
|
||||
self.add_item_entity_drawn(stack, position, None, true, 10, age);
|
||||
}
|
||||
|
||||
/// `add_item_entity`; `constructor_motion` draws the five-argument
|
||||
@@ -716,6 +730,7 @@ impl Game<'_> {
|
||||
velocity: Option<[f64; 3]>,
|
||||
constructor_motion: bool,
|
||||
pickup_delay: u32,
|
||||
age: i32,
|
||||
) -> [f64; 3] {
|
||||
let (item, count) = (stack.item.clone(), stack.count as i32);
|
||||
self.next_entity += 1;
|
||||
@@ -738,6 +753,7 @@ impl Game<'_> {
|
||||
rotation: [yaw, 0.0],
|
||||
pickup_delay: i64::from(pickup_delay),
|
||||
random: own,
|
||||
age,
|
||||
..ItemEntity::new(
|
||||
self.next_entity,
|
||||
stack,
|
||||
@@ -1017,14 +1033,10 @@ mod escape_tests {
|
||||
let mut game = server_game();
|
||||
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
|
||||
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
|
||||
// Entity-ticking chunks also need the surrounding loaded chunks.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.entity_ticking_area()(8, 8));
|
||||
let stone = game
|
||||
@@ -1056,3 +1068,6 @@ mod escape_tests {
|
||||
#[cfg(test)]
|
||||
#[path = "item_void_tests.rs"]
|
||||
mod item_void_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "wither_star_tests.rs"]
|
||||
mod wither_star_tests;
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
containers::{BlockEntity, Jukebox},
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
mobs::Species,
|
||||
mobs::{Species, Who},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
@@ -53,33 +53,49 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn set_has_record(&mut self, pos: [i32; 3], has: bool) {
|
||||
self.set_has_record_notify(pos, has, true);
|
||||
fn set_has_record(&mut self, pos: [i32; 3], has: bool) -> bool {
|
||||
self.set_has_record_notify(pos, has, true)
|
||||
}
|
||||
/// The state change alone (`level.setBlock` with flag 2); `notify` adds
|
||||
/// `notifyItemChangedInJukebox`'s `block_change`, which
|
||||
/// `JukeboxBlock.setPlacedBy` does not post.
|
||||
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) {
|
||||
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut s) = registry.state(state).cloned() else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
s.properties.insert(
|
||||
"has_record".into(),
|
||||
if has { "true" } else { "false" }.into(),
|
||||
);
|
||||
if let Some(new) = registry.id(&s) {
|
||||
let p = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
|
||||
});
|
||||
// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming
|
||||
// or going is a block change the sculk sensors hear.
|
||||
if notify && self.world.block(pos[0], pos[1], pos[2]) != state {
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
let Some(new) = registry.id(&s) else {
|
||||
return false;
|
||||
};
|
||||
let p = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
|
||||
});
|
||||
if notify {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(state));
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `JukeboxBlock.HAS_RECORD`, the state both clicks are decided by.
|
||||
fn jukebox_has_record(&self, pos: [i32; 3]) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("has_record"))
|
||||
.is_some_and(|v| v == "true")
|
||||
}
|
||||
/// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the block's
|
||||
/// `has_record`, then `BLOCK_CHANGE` with the state it now has.
|
||||
fn jukebox_record_changed(&mut self, pos: [i32; 3], has: bool) {
|
||||
self.set_has_record(pos, has);
|
||||
}
|
||||
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
|
||||
/// `block_entity_data` carries a `RecordItem` keeps that record, so
|
||||
@@ -121,9 +137,10 @@ impl Game<'_> {
|
||||
{
|
||||
j.playing = true;
|
||||
j.song_clock_present = true;
|
||||
j.loaded_clock = false;
|
||||
j.ticks = 0;
|
||||
}
|
||||
self.set_has_record(pos, true);
|
||||
self.jukebox_record_changed(pos, true);
|
||||
self.start_song(pos, &song);
|
||||
self.jukebox_changed(pos);
|
||||
}
|
||||
@@ -141,15 +158,17 @@ impl Game<'_> {
|
||||
was_playing = j.playing;
|
||||
j.playing = false;
|
||||
j.song_clock_present = false;
|
||||
j.loaded_clock = false;
|
||||
j.ticks = 0;
|
||||
}
|
||||
// `setTheItem(EMPTY)` first tells the block, then stops the song.
|
||||
self.jukebox_record_changed(pos, false);
|
||||
// `JukeboxSongPlayer.stop` posts only while a song plays.
|
||||
if was_playing {
|
||||
self.stop_song_heard(pos);
|
||||
} else {
|
||||
self.stop_song(pos);
|
||||
}
|
||||
self.set_has_record(pos, false);
|
||||
self.jukebox_changed(pos);
|
||||
}
|
||||
pub(super) fn jukebox_set_item(&mut self, pos: [i32; 3], stack: Option<ItemStack>) {
|
||||
@@ -164,8 +183,9 @@ impl Game<'_> {
|
||||
j.items[0] = stack;
|
||||
j.playing = song.is_some();
|
||||
j.song_clock_present = song.is_some();
|
||||
j.loaded_clock = false;
|
||||
j.ticks = 0;
|
||||
self.set_has_record(pos, has_record);
|
||||
self.jukebox_record_changed(pos, has_record);
|
||||
match song {
|
||||
Some(song) => self.start_song(pos, &song),
|
||||
None if was_playing => self.stop_song_heard(pos),
|
||||
@@ -184,7 +204,10 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self.jukebox(pos).is_some_and(|j| j.record().is_some()) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
// The state, not the slot, decides the click: `JukeboxBlock` with a
|
||||
// record pops it out, without one takes a disc.
|
||||
if self.jukebox_has_record(pos) {
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
|
||||
if self.hand != Hand::Main {
|
||||
return false;
|
||||
@@ -199,7 +222,7 @@ impl Game<'_> {
|
||||
let Some(song) = jukebox_playable(stack, self.res.items()) else {
|
||||
return false;
|
||||
};
|
||||
// JukeboxPlayable.tryInsertIntoJukebox
|
||||
// JukeboxPlayable.tryInsertIntoJukebox: the one disc the player gives up.
|
||||
let record = ItemStack {
|
||||
count: 1,
|
||||
..stack.clone()
|
||||
@@ -207,7 +230,9 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.award_custom_stat("play_record", 1);
|
||||
// `JukeboxBlockEntity.setTheItem`: the slot takes the disc, the block
|
||||
// tells its listeners, and the song plays (`play`); a disc already in
|
||||
// the slot (under a state that lost its record) is replaced.
|
||||
self.world.block_entities.insert(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
BlockEntity::Jukebox(Jukebox {
|
||||
@@ -215,13 +240,23 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
playing: true,
|
||||
song_clock_present: true,
|
||||
loaded_clock: false,
|
||||
}),
|
||||
);
|
||||
self.set_has_record(pos, true);
|
||||
self.jukebox_record_changed(pos, true);
|
||||
if let Some(song) = song {
|
||||
self.start_song(pos, &song);
|
||||
}
|
||||
self.jukebox_changed(pos);
|
||||
// `tryInsertIntoJukebox`: the same change again, against the state this
|
||||
// click found, with the player as its source.
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
Some(Who::Player),
|
||||
Some(state),
|
||||
);
|
||||
self.award_custom_stat("play_record", 1);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
@@ -235,14 +270,8 @@ impl Game<'_> {
|
||||
if &*self.res.facts().get(state).name != "minecraft:jukebox" {
|
||||
return false;
|
||||
}
|
||||
let has_record = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("has_record"))
|
||||
.is_some_and(|v| v == "true");
|
||||
has_record || held.is_some_and(|stack| jukebox_playable(stack, self.res.items()).is_some())
|
||||
self.jukebox_has_record(pos)
|
||||
|| held.is_some_and(|stack| jukebox_playable(stack, self.res.items()).is_some())
|
||||
}
|
||||
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
|
||||
fn start_song(&mut self, pos: [i32; 3], song: &str) {
|
||||
@@ -317,20 +346,28 @@ impl Game<'_> {
|
||||
m.extra.party_jukebox = playing.then_some(pos);
|
||||
}
|
||||
}
|
||||
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
|
||||
/// `popOutTheItem`: the disc above the jukebox, and the song stops. An
|
||||
/// empty slot pops nothing out (`if (!itemBeforePoppingOut.isEmpty())`),
|
||||
/// whatever the state says.
|
||||
fn eject_record(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let mut was_playing = false;
|
||||
let record = match self.world.block_entities.get_mut(&key) {
|
||||
let (record, was_playing) = match self.world.block_entities.get_mut(&key) {
|
||||
Some(BlockEntity::Jukebox(j)) => {
|
||||
was_playing = j.playing;
|
||||
if j.items[0].is_none() {
|
||||
return;
|
||||
}
|
||||
let was_playing = j.playing;
|
||||
j.playing = false;
|
||||
j.song_clock_present = false;
|
||||
j.loaded_clock = false;
|
||||
j.ticks = 0;
|
||||
j.items[0].take()
|
||||
(j.items[0].take(), was_playing)
|
||||
}
|
||||
_ => None,
|
||||
_ => return,
|
||||
};
|
||||
// `removeTheItem` -> `setTheItem(EMPTY)`: the block loses its record,
|
||||
// then the song stops.
|
||||
self.jukebox_record_changed(pos, false);
|
||||
// `JukeboxSongPlayer.stop` posts only while a song plays: a disc
|
||||
// taken out after its song ended is silent.
|
||||
if was_playing {
|
||||
@@ -347,7 +384,6 @@ impl Game<'_> {
|
||||
);
|
||||
self.spawn_item_own(record, at, 10);
|
||||
}
|
||||
self.set_has_record(pos, false);
|
||||
self.jukebox_changed(pos);
|
||||
}
|
||||
/// A broken jukebox stops its song (its disc drops with the contents).
|
||||
@@ -359,11 +395,11 @@ impl Game<'_> {
|
||||
let playing = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Jukebox(j)) if j.playing => j.record().and_then(|r| {
|
||||
let song = song_of_record(r, self.items())?;
|
||||
Some((song, j.ticks))
|
||||
Some((song, j.ticks, j.loaded_clock))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let Some((song, ticks)) = playing else {
|
||||
let Some((song, ticks, loaded)) = playing else {
|
||||
return;
|
||||
};
|
||||
let pos = [key.0, key.1, key.2];
|
||||
@@ -374,13 +410,26 @@ impl Game<'_> {
|
||||
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
|
||||
j.playing = false;
|
||||
j.song_clock_present = false;
|
||||
j.loaded_clock = false;
|
||||
j.ticks = 0;
|
||||
}
|
||||
// `setSongWithoutPlaying` never adopted a clock past its song's
|
||||
// end: loading such a jukebox is silent, while a song that ends
|
||||
// here stops out loud.
|
||||
if loaded {
|
||||
return;
|
||||
}
|
||||
self.stop_song_heard(pos);
|
||||
self.jukebox_changed(pos);
|
||||
return;
|
||||
}
|
||||
if !self.jukebox_sounds.contains_key(&key) && ticks == 0 {
|
||||
if loaded {
|
||||
// The song a save kept goes on without its sound: vanilla starts
|
||||
// one only from `play` (level event 1010).
|
||||
if let Some(BlockEntity::Jukebox(j)) = self.world.block_entities.get_mut(&key) {
|
||||
j.loaded_clock = false;
|
||||
}
|
||||
} else if !self.jukebox_sounds.contains_key(&key) && ticks == 0 {
|
||||
self.start_song(pos, &song);
|
||||
}
|
||||
if ticks % 20 == 0 {
|
||||
@@ -452,12 +501,19 @@ pub(super) fn jukebox_signals(
|
||||
) -> Option<(i32, i32)> {
|
||||
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
Some(BlockEntity::Jukebox(j)) => {
|
||||
let comparator = j
|
||||
.record()
|
||||
.and_then(|r| song_of_record(r, items))
|
||||
.and_then(|s| items.songs.get(&s).map(|song| song.comparator))
|
||||
let song = j.record().and_then(|r| song_of_record(r, items));
|
||||
let comparator = song
|
||||
.as_deref()
|
||||
.and_then(|s| items.songs.get(s).map(|song| song.comparator))
|
||||
.unwrap_or(0);
|
||||
Some((if j.playing { 15 } else { 0 }, comparator))
|
||||
// `JukeboxSongPlayer.isPlaying`, less a clock a save brought back
|
||||
// past its song's end: the first tick drops that one.
|
||||
let stale = j.loaded_clock
|
||||
&& song
|
||||
.as_deref()
|
||||
.and_then(|s| items.songs.get(s))
|
||||
.is_some_and(|s| s.finished(j.ticks));
|
||||
Some((if j.playing && !stale { 15 } else { 0 }, comparator))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -578,18 +634,24 @@ mod jukebox_play_tests {
|
||||
);
|
||||
// The song is still playing after 41 ticks.
|
||||
assert!(
|
||||
lines.iter().all(|l| l.ends_with("minecraft:jukebox_play")),
|
||||
!lines
|
||||
.iter()
|
||||
.any(|l| l.ends_with("minecraft:jukebox_stop_play")),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A jukebox whose song is stopped posts no play event: only the twenty
|
||||
/// ticks of a playing song do.
|
||||
/// ticks of a playing song do. Its insert's `BLOCK_CHANGE` events are the
|
||||
/// only ones there.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_stopped_jukebox_posts_no_play_event() {
|
||||
let Some(lines) = playing(0) else { return };
|
||||
assert!(lines.is_empty(), "{lines:?}");
|
||||
assert!(
|
||||
lines.iter().all(|l| l.ends_with("minecraft:block_change")),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +718,8 @@ mod saved_song_clock_tests {
|
||||
assert!(game.run_command("setblock 8 199 4 stone"));
|
||||
assert!(game.run_command("setblock 8 200 4 jukebox[has_record=true]{RecordItem:{id:\"minecraft:music_disc_cat\",count:1,components:{\"minecraft:custom_data\":{h032:1}}}}"));
|
||||
let j = game.jukebox([8, 200, 4]).unwrap();
|
||||
assert!(j.playing, "preserve legacy record-only adoption");
|
||||
// `setSongWithoutPlaying`: a record whose save had no clock is silent.
|
||||
assert!(!j.playing, "a record without a clock does not play on load");
|
||||
assert_eq!(j.ticks, 0);
|
||||
let saved = game.block_entity_nbt([8, 200, 4]).unwrap();
|
||||
assert_eq!(
|
||||
@@ -692,6 +755,7 @@ mod jukebox_component_tests {
|
||||
ticks: 0,
|
||||
song_clock_present: true,
|
||||
playing: true,
|
||||
loaded_clock: false,
|
||||
}),
|
||||
);
|
||||
jukebox_signals(
|
||||
@@ -775,6 +839,10 @@ mod jukebox_component_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "jukebox_tests.rs"]
|
||||
mod jukebox_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod block_change_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Jukeboxes against `JukeboxBlock`, `JukeboxBlockEntity` and `JukeboxPlayable`:
|
||||
//! what a load brings back of a saved song, the `BLOCK_CHANGE` of an insert and
|
||||
//! of an eject, and which click the `has_record` state allows.
|
||||
use super::Game;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
containers::{BlockEntity, Jukebox},
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The game events posted while `f` runs, as `(event, position, source)`.
|
||||
fn record(f: impl FnOnce()) -> Vec<(&'static str, [f64; 3], Option<Who>)> {
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
|
||||
f();
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| e.borrow_mut().take().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// A windowless integrated-server game from the prepared resources with a
|
||||
/// resident chunk; `None` without them.
|
||||
fn with_game<R>(f: impl FnOnce(&mut Game<'static>) -> R) -> Option<R> {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.ok()?;
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
Some(f(&mut game))
|
||||
}
|
||||
|
||||
const AT: [f64; 3] = [0.5, 64.5, 0.5];
|
||||
|
||||
/// The jukebox at (0, 64, 0) in the state `record` names; its state id.
|
||||
fn place_jukebox(game: &mut Game<'_>, record: bool) -> BlockStateId {
|
||||
let registry = game.pack.assets.registry;
|
||||
let mut state = registry
|
||||
.state(registry.default_state("minecraft:jukebox").unwrap())
|
||||
.unwrap()
|
||||
.clone();
|
||||
state.properties.insert(
|
||||
"has_record".into(),
|
||||
if record { "true" } else { "false" }.into(),
|
||||
);
|
||||
let id = registry.id(&state).unwrap();
|
||||
game.world.set(0, 64, 0, id).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// A jukebox block entity as a save writes it, loaded back into the world at
|
||||
/// (0, 64, 0): `RecordItem` and, with a `clock`, `ticks_since_song_started`.
|
||||
fn load_saved(game: &mut Game<'_>, clock: Option<i64>) {
|
||||
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
|
||||
let (tag, _) = horizon_world::nbt::parse_snbt(&format!(
|
||||
"{{id:\"minecraft:jukebox\",x:0,y:64,z:0,\
|
||||
RecordItem:{{id:\"minecraft:music_disc_11\",count:1,}},{clock}}}"
|
||||
))
|
||||
.unwrap();
|
||||
let (pos, model) = horizon_client::save::decode_block_entity(&tag).unwrap();
|
||||
game.world.block_entities.insert(pos, model);
|
||||
}
|
||||
|
||||
/// `JukeboxBlockEntity.setTheItem` posts `BLOCK_CHANGE` with the state the
|
||||
/// block now has, and `JukeboxPlayable.tryInsertIntoJukebox` then posts the
|
||||
/// same change again with the player as its source (against the state the
|
||||
/// click found). `popOutTheItem` -> `setTheItem(EMPTY)` posts one on the way
|
||||
/// out, before the song's own `JUKEBOX_STOP_PLAY`. A sculk sensor beside a
|
||||
/// jukebox heard neither insert nor eject: only the song's end.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_insert_posts_two_block_changes_and_an_eject_one() {
|
||||
let Some((inserted, popped)) = with_game(|game| {
|
||||
place_jukebox(game, false);
|
||||
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
|
||||
let inserted = record(|| {
|
||||
assert!(
|
||||
game.use_jukebox([0, 64, 0], Some(&disc)),
|
||||
"the disc goes in"
|
||||
);
|
||||
});
|
||||
let popped = record(|| {
|
||||
assert!(
|
||||
game.use_jukebox([0, 64, 0], None),
|
||||
"the empty hand pops it out"
|
||||
);
|
||||
});
|
||||
(inserted, popped)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(
|
||||
inserted,
|
||||
vec![
|
||||
("minecraft:block_change", AT, None),
|
||||
("minecraft:block_change", AT, Some(Who::Player)),
|
||||
],
|
||||
"the slot's change, then the player's"
|
||||
);
|
||||
assert_eq!(
|
||||
popped,
|
||||
vec![
|
||||
("minecraft:block_change", AT, None),
|
||||
("minecraft:jukebox_stop_play", AT, None),
|
||||
],
|
||||
"the record leaves the block before its song stops"
|
||||
);
|
||||
}
|
||||
|
||||
/// `JukeboxBlock.useWithoutItem` only pops when the state says a record is
|
||||
/// there, and `popOutTheItem` pops nothing from an empty slot; `useItemOn`
|
||||
/// only inserts when it says there is none, and replacing the slot's item is
|
||||
/// `setTheItem`'s own doing. Horizon decided both by the slot.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_has_record_state_decides_both_clicks() {
|
||||
let Some((ignored, replaced)) = with_game(|game| {
|
||||
// has_record with nothing in the slot: the click pops nothing and the
|
||||
// disc stays in hand, so the state stays as it is.
|
||||
let recorded = place_jukebox(game, true);
|
||||
game.world
|
||||
.block_entities
|
||||
.insert((0, 64, 0), BlockEntity::Jukebox(Jukebox::default()));
|
||||
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
|
||||
let ignored = record(|| {
|
||||
assert!(
|
||||
game.use_jukebox([0, 64, 0], Some(&disc)),
|
||||
"the click is consumed"
|
||||
);
|
||||
});
|
||||
assert_eq!(game.world.block(0, 64, 0), recorded, "keeps its record");
|
||||
assert!(
|
||||
game.jukebox([0, 64, 0]).unwrap().record().is_none(),
|
||||
"the held disc is not inserted"
|
||||
);
|
||||
|
||||
// No record in the state with a disc in the slot: the held one replaces
|
||||
// it (`JukeboxBlockEntity.setTheItem`), it does not eject the old one.
|
||||
place_jukebox(game, false);
|
||||
let old = ItemStack::new("minecraft:music_disc_13", 1);
|
||||
game.world.block_entities.insert(
|
||||
(0, 64, 0),
|
||||
BlockEntity::Jukebox(Jukebox {
|
||||
items: [Some(old)],
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let replaced = record(|| {
|
||||
assert!(
|
||||
game.use_jukebox([0, 64, 0], Some(&disc)),
|
||||
"the disc goes in"
|
||||
);
|
||||
});
|
||||
let j = game.jukebox([0, 64, 0]).unwrap();
|
||||
assert_eq!(&*j.record().unwrap().item, "minecraft:music_disc_cat");
|
||||
assert!(j.playing, "the new disc plays");
|
||||
(ignored, replaced)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
assert!(ignored.is_empty(), "nothing happened: {ignored:?}");
|
||||
assert_eq!(
|
||||
replaced,
|
||||
vec![
|
||||
("minecraft:block_change", AT, None),
|
||||
("minecraft:block_change", AT, Some(Who::Player)),
|
||||
],
|
||||
"the old disc is replaced, not popped out"
|
||||
);
|
||||
}
|
||||
|
||||
/// `JukeboxBlockEntity.loadAdditional` hands a saved clock to
|
||||
/// `JukeboxSongPlayer.setSongWithoutPlaying`: a song under its length comes
|
||||
/// back playing, without `play`'s level event 1010 and its "Now Playing", a
|
||||
/// clock past the song's end is dropped without a `JUKEBOX_STOP_PLAY`, and a
|
||||
/// record saved without a clock is no song at all.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_saved_song_comes_back_without_its_sound() {
|
||||
let Some(cases) = with_game(|game| {
|
||||
place_jukebox(game, true);
|
||||
let signal = |game: &Game<'_>| {
|
||||
let p = horizon_core::position::BlockPos::new(0, 64, 0);
|
||||
super::jukebox_signals(&game.world, game.items(), p)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let mut cases = Vec::new();
|
||||
for clock in [Some(41), Some(10_000), None, Some(0)] {
|
||||
load_saved(game, clock);
|
||||
let events = record(|| game.tick_jukebox((0, 64, 0)));
|
||||
let j = game.jukebox([0, 64, 0]).unwrap();
|
||||
cases.push((
|
||||
clock,
|
||||
(j.playing, j.ticks, j.song_clock_present),
|
||||
signal(game),
|
||||
game.titles.overlay.is_some(),
|
||||
events,
|
||||
));
|
||||
}
|
||||
cases
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let at = |clock: Option<i64>| cases.iter().find(|c| c.0 == clock).unwrap();
|
||||
// Mid-song and at its start: the clock goes on, at 15, quietly; only the
|
||||
// twenty-tick chatter of a song that is running.
|
||||
assert_eq!(at(Some(41)).1, (true, 42, true), "the clock goes on");
|
||||
assert_eq!(at(Some(41)).2, 15);
|
||||
assert!(!at(Some(41)).3, "nothing says \"Now Playing\" on a load");
|
||||
assert!(at(Some(41)).4.is_empty(), "{:?}", at(Some(41)).4);
|
||||
assert_eq!(at(Some(0)).1, (true, 1, true));
|
||||
assert!(!at(Some(0)).3, "a clock saved at its start stays quiet");
|
||||
assert_eq!(
|
||||
at(Some(0)).4,
|
||||
vec![("minecraft:jukebox_play", AT, None)],
|
||||
"the first tick's game event is vanilla's"
|
||||
);
|
||||
// Past the end: gone, and silent about going.
|
||||
assert_eq!(at(Some(10_000)).1, (false, 0, false));
|
||||
assert_eq!(at(Some(10_000)).2, 0, "a dropped song powers nothing");
|
||||
assert!(at(Some(10_000)).4.is_empty(), "{:?}", at(Some(10_000)).4);
|
||||
// A record saved without a clock: no song, no signal, no event.
|
||||
assert_eq!(at(None).1, (false, 0, false));
|
||||
assert_eq!(at(None).2, 0);
|
||||
assert!(!at(None).3, "a record alone starts no song");
|
||||
assert!(at(None).4.is_empty(), "{:?}", at(None).4);
|
||||
}
|
||||
|
||||
/// The same three cases on the load path itself, which needs no resources: only
|
||||
/// a saved clock brings a song back, and it comes back unstarted.
|
||||
#[test]
|
||||
fn only_a_saved_clock_brings_a_song_back() {
|
||||
let load = |clock: Option<i64>| {
|
||||
let clock = clock.map_or(String::new(), |n| format!("ticks_since_song_started:{n}L,"));
|
||||
let (tag, _) = horizon_world::nbt::parse_snbt(&format!(
|
||||
"{{id:\"minecraft:jukebox\",x:0,y:64,z:0,\
|
||||
RecordItem:{{id:\"minecraft:music_disc_11\",count:1,}},{clock}}}"
|
||||
))
|
||||
.unwrap();
|
||||
match horizon_client::save::decode_block_entity(&tag).unwrap().1 {
|
||||
BlockEntity::Jukebox(j) => j,
|
||||
other => panic!("not a jukebox: {other:?}"),
|
||||
}
|
||||
};
|
||||
let mid = load(Some(41));
|
||||
assert_eq!(mid.ticks, 41);
|
||||
assert!(
|
||||
mid.playing && mid.song_clock_present,
|
||||
"a saved clock brings the song back"
|
||||
);
|
||||
let unclocked = load(None);
|
||||
assert_eq!(
|
||||
unclocked.record().map(|r| &*r.item),
|
||||
Some("minecraft:music_disc_11")
|
||||
);
|
||||
assert_eq!(unclocked.ticks, 0);
|
||||
assert!(
|
||||
!unclocked.playing && !unclocked.song_clock_present,
|
||||
"a record alone is no song"
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ fn game() -> Game<'static> {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"fill 0 63 0 15 63 15 stone",
|
||||
|
||||
@@ -1161,7 +1161,7 @@ impl Game<'_> {
|
||||
let scale = 1i32 << m.scale;
|
||||
let start_x = m.center[0] / scale - 64;
|
||||
let start_z = m.center[1] / scale - 64;
|
||||
let sea_level = 63;
|
||||
let sea_level = horizon_world::level::sea_level_of(self.level_key());
|
||||
let quarts: Vec<[i32; 3]> = (0..128)
|
||||
.flat_map(|row| {
|
||||
(0..128).map(move |column| {
|
||||
|
||||
@@ -656,10 +656,16 @@ impl Game<'_> {
|
||||
self.station_sound(sound);
|
||||
}
|
||||
} else if kind == Kind::Stonecutter {
|
||||
// `StonecutterMenu.container`'s listener runs on every change of the
|
||||
// input slot (`slotsChanged` and `containerChanged` both hang off
|
||||
// it): a recipe button or an inventory click is not one.
|
||||
let input_changed = before[0] != menu.slot(0).cloned();
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
s.selected = usize::try_from(menu.data[0]).ok();
|
||||
s.stone = std::mem::take(&mut menu.stone);
|
||||
s.stonecutter_changed();
|
||||
if input_changed {
|
||||
s.stonecutter_changed();
|
||||
}
|
||||
}
|
||||
for sound in sounds.into_iter().filter(|_| !predicting) {
|
||||
self.station_sound(sound);
|
||||
|
||||
@@ -233,6 +233,19 @@ fn pickup_instead_of_push(species: Species, is_passenger: bool, cart_is_vehicle:
|
||||
!cart_is_vehicle && !is_passenger && species != Species::IronGolem
|
||||
}
|
||||
|
||||
/// `EntitySelector.pushableBy` asks `isPushable`: `Shulker` takes no push
|
||||
/// (`Mob::pushable` leaves it out) but still counts, so a cart offers it
|
||||
/// `startRiding` like any other living entity.
|
||||
fn offered_pickup(pushable: bool, species: Species, alive: bool) -> bool {
|
||||
pushable || species == Species::Shulker && alive
|
||||
}
|
||||
|
||||
/// `Shulker.push(Entity)` is empty, so a shulker that is not picked up (it
|
||||
/// already rides something) neither moves nor slows the cart.
|
||||
fn takes_push_from_cart(species: Species) -> bool {
|
||||
species != Species::Shulker
|
||||
}
|
||||
|
||||
/// `Entity.canRide` for a cart: the boarding cooldown, and a warden or the
|
||||
/// wither never rides (`canRide` false).
|
||||
fn can_ride_cart(species: Species, boarding_cooldown: i32) -> bool {
|
||||
@@ -1794,7 +1807,10 @@ impl Game<'_> {
|
||||
let (teams, own) = (self.mob_teams(), self.team_with_owner(id, || None));
|
||||
near.iter()
|
||||
.map(|&j| &self.mobs[j as usize])
|
||||
.filter(|m| m.pushable() && pushable_by(own, teams.of(Who::Mob(m.id)), true))
|
||||
.filter(|m| {
|
||||
offered_pickup(m.pushable(), m.species(), m.alive())
|
||||
&& pushable_by(own, teams.of(Who::Mob(m.id)), true)
|
||||
})
|
||||
.map(|m| m.id)
|
||||
.collect()
|
||||
};
|
||||
@@ -1827,6 +1843,9 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !takes_push_from_cart(species) {
|
||||
continue;
|
||||
}
|
||||
let other = self.mobs[j].position;
|
||||
let Some(v) = super::boat_passengers::push_impulse(pos, other) else {
|
||||
continue;
|
||||
@@ -2144,51 +2163,6 @@ impl Game<'_> {
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `EntityType.createDefaultStackConfig` on a vehicle an item made: the
|
||||
/// item's custom name and custom data (`applyComponentsFromItemStack`:
|
||||
/// `Entity.applyImplicitComponent`'s two, saved as `CustomName` and
|
||||
/// `data`), then its
|
||||
/// `minecraft:entity_data` loaded over the vehicle (`updateCustomEntityTag`)
|
||||
/// when it names this type. An op-only type (command block and spawner
|
||||
/// minecarts) takes it from an op player only, never from a dispenser
|
||||
/// (`by_player` false).
|
||||
pub(super) fn apply_vehicle_stack_config(
|
||||
&mut self,
|
||||
target: super::commands::Target,
|
||||
kind: &str,
|
||||
stack: &ItemStack,
|
||||
by_player: bool,
|
||||
) {
|
||||
use horizon_world::nbt::Tag;
|
||||
let name = stack.data().and_then(|d| d.custom_name.clone());
|
||||
let custom = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:custom_data"))
|
||||
.cloned();
|
||||
let op = by_player && self.acting_player_is_op();
|
||||
let data =
|
||||
horizon_client::block_entity_item::spawn_egg_entity_data(stack, kind, op).cloned();
|
||||
if name.is_none() && custom.is_none() && data.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(mut own) = self.entity_nbt(target) else {
|
||||
return;
|
||||
};
|
||||
if let Tag::Compound(map) = &mut own {
|
||||
if let Some(name) = name {
|
||||
map.insert("CustomName".into(), name);
|
||||
}
|
||||
if let Some(custom) = custom {
|
||||
map.insert("data".into(), custom);
|
||||
}
|
||||
}
|
||||
let tag = match &data {
|
||||
Some(data) => horizon_client::data_cmd::merged(&own, data),
|
||||
None => own,
|
||||
};
|
||||
// The vehicle's own compound, its name and data merged in: it loads back.
|
||||
let _ = self.apply_entity_nbt(target, tag);
|
||||
}
|
||||
/// `MinecartItem.useOn`: a cart on the clicked rail.
|
||||
pub(super) fn place_minecart(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let Some(kind) = CartKind::from_id(&stack.item) else {
|
||||
@@ -2210,7 +2184,7 @@ impl Game<'_> {
|
||||
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
|
||||
self.carts.push(cart);
|
||||
let id = self.next_entity;
|
||||
self.apply_vehicle_stack_config(super::commands::Target::Cart(id), kind.id(), stack, true);
|
||||
self.apply_item_stack_config(super::commands::Target::Cart(id), kind.id(), stack, true);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
@@ -3382,6 +3356,25 @@ mod tests {
|
||||
assert!(!can_ride_cart(Species::Warden, 0));
|
||||
}
|
||||
|
||||
/// A shulker beside the rail is picked up although `Mob::pushable` leaves it out.
|
||||
#[test]
|
||||
fn a_cart_picks_up_a_shulker_but_not_a_dead_one() {
|
||||
assert!(offered_pickup(false, Species::Shulker, true));
|
||||
assert!(!offered_pickup(false, Species::Shulker, false));
|
||||
assert!(!offered_pickup(false, Species::Bat, true));
|
||||
assert!(offered_pickup(true, Species::Cow, true));
|
||||
assert!(pickup_instead_of_push(Species::Shulker, false, false));
|
||||
assert!(can_ride_cart(Species::Shulker, 0));
|
||||
}
|
||||
|
||||
/// A shulker already riding a boat is no pickup candidate and `Shulker.push` is a no-op.
|
||||
#[test]
|
||||
fn a_mounted_shulker_does_not_push_a_cart() {
|
||||
assert!(!pickup_instead_of_push(Species::Shulker, true, false));
|
||||
assert!(!takes_push_from_cart(Species::Shulker));
|
||||
assert!(takes_push_from_cart(Species::Cow));
|
||||
}
|
||||
|
||||
/// `WitherBoss.canRide` returns false, so a moving cart cannot scoop the boss up.
|
||||
#[test]
|
||||
fn a_wither_never_rides_a_cart() {
|
||||
|
||||
@@ -268,3 +268,40 @@ fn efficiency_applies_to_a_same_item_swap() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_stacks_attribute_modifiers_replace_its_items_attack_damage() {
|
||||
with_survival_game(|game| {
|
||||
// Player.attack reads getAttributeValue(ATTACK_DAMAGE): the base 1
|
||||
// plus what detectEquipmentUpdates gave the player for the held stack
|
||||
// (ItemStack.forEachModifier, the stack's ATTRIBUTE_MODIFIERS). An
|
||||
// iron pickaxe's own modifier is +3 (ToolMaterial.IRON's 2 plus the
|
||||
// pickaxe's 1); the stack's +20 replaces it, so the hit is 21 (#14641).
|
||||
for (stack, damage) in [
|
||||
("minecraft:iron_pickaxe", 4.0),
|
||||
(
|
||||
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"minecraft:attack_damage\",id:\"minecraft:base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"mainhand\"}]]",
|
||||
21.0,
|
||||
),
|
||||
// Identifier.CODEC reads bare names as `minecraft:`'s: the same
|
||||
// modifier, so the attribute the host applies it to is found.
|
||||
(
|
||||
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"attack_damage\",id:\"base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"mainhand\"}]]",
|
||||
21.0,
|
||||
),
|
||||
// In the off hand slot group, it does not apply to the main hand.
|
||||
(
|
||||
"minecraft:iron_pickaxe[attribute_modifiers=[{type:\"minecraft:attack_damage\",id:\"minecraft:base_attack_damage\",amount:20.0,operation:\"add_value\",slot:\"offhand\"}]]",
|
||||
1.0,
|
||||
),
|
||||
("minecraft:iron_pickaxe[!attribute_modifiers]", 1.0),
|
||||
] {
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace entity @s weapon.mainhand with {stack}"
|
||||
)));
|
||||
game.tick_hand();
|
||||
assert_eq!(game.attack_damage(), damage, "{stack}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -44,18 +44,13 @@ fn game() -> Game<'static> {
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
// Entities tick once every chunk within 2 columns is loaded
|
||||
// (ChunkMap.prepareEntityTickingChunk, #13953).
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(x, z))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
|
||||
game
|
||||
}
|
||||
|
||||
@@ -992,6 +992,8 @@
|
||||
use super::native_data_maps::NativeDataMapType;
|
||||
use super::native_guards::{box_meets, finite, finite_params, mod_image_fits};
|
||||
use super::native_item_energy::ItemEnergyBuffer;
|
||||
use super::native_level_effects::{mod_particles_ok, mod_sound_ok, sound_id};
|
||||
use super::native_menus::viewer_changes;
|
||||
use super::native_world_edit::{property_change, with_property};
|
||||
use super::{
|
||||
Game, GameLevel, LevelDimension, Screen,
|
||||
@@ -8504,6 +8506,18 @@ impl Game<'_> {
|
||||
blocks,
|
||||
);
|
||||
}
|
||||
/// An explosion at `center` in `dimension` is about to hurt and push `entities`, the ids of
|
||||
/// the entities in its reach (players aside: `native_living_incoming_damage` hears them),
|
||||
/// after `native_explosion_detonate`: `ServerExplosion.hurtEntities`. Take out the ids to
|
||||
/// spare: they are neither hurt nor pushed. Server-side. API @API:explosion_entities@.
|
||||
pub(super) fn native_explosion_entities(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
center: [f64; 3],
|
||||
entities: &mut Vec<u32>,
|
||||
) {
|
||||
mod_api::dispatch_explosion_entities(self, mod_api::mods(), dimension, center, entities);
|
||||
}
|
||||
/// An explosion at `center` in `dimension`, about to break `blocks`, pushes
|
||||
/// `entity` (None for a player, whom `native_acting_player` names):
|
||||
/// `ServerExplosion.hurtEntities`, after the knockback is calculated. Change `knockback_velocity` to change
|
||||
@@ -9991,113 +10005,6 @@ fn mob_source(damage: &Damage) -> HurtSource<'static> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` is a sound event's id, `namespace:path` as `Identifier`
|
||||
/// allows: the namespace of `[a-z0-9_.-]`, the path also with `/`.
|
||||
/// Whether a native mod's sound can be sent: a namespaced event, a sound
|
||||
/// source, a position inside the world's range and a finite, non-negative
|
||||
/// volume and pitch.
|
||||
pub(super) fn mod_sound_ok(
|
||||
sound: &str,
|
||||
at: [f64; 3],
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
) -> bool {
|
||||
let position_ok = at
|
||||
.into_iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX) / 8.0);
|
||||
let gain_ok = volume.is_finite() && volume >= 0.0 && pitch.is_finite() && pitch >= 0.0;
|
||||
sound_id(sound)
|
||||
&& source < horizon_client::settings::SOUND_SOURCES.len()
|
||||
&& position_ok
|
||||
&& gain_ok
|
||||
}
|
||||
|
||||
/// Whether a native mod's particles can be sent. Block particle types need
|
||||
/// a known state (`state_ok`); item, trail and vibration particles aren't
|
||||
/// supported by the network dispatch; a mod's own type must be namespaced.
|
||||
pub(super) fn mod_particles_ok(
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
state_ok: impl Fn(BlockStateId) -> bool,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
) -> bool {
|
||||
use ParticleType as T;
|
||||
// A native mod's own type: the name has to be namespaced, and a client
|
||||
// draws it only when its pack has the sprite set.
|
||||
if let Some(name) = &options.mod_particle {
|
||||
let namespaced = name
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
|
||||
if !namespaced {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let supported = match options.ty {
|
||||
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
|
||||
state.is_some_and(state_ok)
|
||||
}
|
||||
T::Item | T::Trail | T::Vibration => false,
|
||||
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
|
||||
.geyser
|
||||
.is_some_and(|(_, impulse)| impulse.is_finite()),
|
||||
_ => true,
|
||||
};
|
||||
supported
|
||||
&& at.into_iter().all(f64::is_finite)
|
||||
&& spread
|
||||
.into_iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
|
||||
&& speed.is_finite()
|
||||
&& speed.abs() <= f32::MAX as f64
|
||||
&& options.color.into_iter().all(f32::is_finite)
|
||||
&& options.to_color.into_iter().all(f32::is_finite)
|
||||
&& options.scale.is_finite()
|
||||
}
|
||||
|
||||
fn sound_id(id: &str) -> bool {
|
||||
let Some((namespace, path)) = id.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
let allowed = |c: char, slash: bool| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || "_.-".contains(c) || (slash && c == '/')
|
||||
};
|
||||
!namespace.is_empty()
|
||||
&& !path.is_empty()
|
||||
&& namespace.chars().all(|c| allowed(c, false))
|
||||
&& path.chars().all(|c| allowed(c, true))
|
||||
}
|
||||
|
||||
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
|
||||
/// changes as `(pos, previous, viewers)`, in position order. A container that
|
||||
/// is no longer counted goes to 0 and leaves `known`.
|
||||
pub(super) fn viewer_changes(
|
||||
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
|
||||
dim: &'static str,
|
||||
mut now: HashMap<(i32, i32, i32), u32>,
|
||||
) -> Vec<((i32, i32, i32), u32, u32)> {
|
||||
let mut changes = Vec::new();
|
||||
known.retain(|&(d, pos), previous| {
|
||||
if d != dim {
|
||||
return true;
|
||||
}
|
||||
let viewers = now.remove(&pos).unwrap_or(0);
|
||||
if viewers != *previous {
|
||||
changes.push((pos, *previous, viewers));
|
||||
*previous = viewers;
|
||||
}
|
||||
viewers > 0
|
||||
});
|
||||
for (pos, viewers) in now {
|
||||
known.insert((dim, pos), viewers);
|
||||
changes.push((pos, 0, viewers));
|
||||
}
|
||||
changes.sort_unstable();
|
||||
changes
|
||||
}
|
||||
|
||||
pub(super) fn dimension_id(dimension: LevelDimension) -> &'static str {
|
||||
match dimension {
|
||||
LevelDimension::Overworld => "minecraft:overworld",
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//! Every player in range hears or sees them, as `ServerLevel.playSound` and
|
||||
//! `sendParticles` send them. They return false on a client, for a handle of
|
||||
//! an earlier open, or for arguments the network can't carry.
|
||||
use super::native_hooks::{mod_particles_ok, mod_sound_ok};
|
||||
use super::{Dim, Game, creatures::NativeSound, lan_particles::QueuedParticle};
|
||||
use horizon_client::mod_api::{LevelRef, ParticleOptions};
|
||||
use horizon_client::particles::ParticleType;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -85,3 +85,82 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` is a sound event's id, `namespace:path` as `Identifier`
|
||||
/// allows: the namespace of `[a-z0-9_.-]`, the path also with `/`.
|
||||
/// Whether a native mod's sound can be sent: a namespaced event, a sound
|
||||
/// source, a position inside the world's range and a finite, non-negative
|
||||
/// volume and pitch.
|
||||
pub(super) fn mod_sound_ok(
|
||||
sound: &str,
|
||||
at: [f64; 3],
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
) -> bool {
|
||||
let position_ok = at
|
||||
.into_iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= f64::from(i32::MAX) / 8.0);
|
||||
let gain_ok = volume.is_finite() && volume >= 0.0 && pitch.is_finite() && pitch >= 0.0;
|
||||
sound_id(sound)
|
||||
&& source < horizon_client::settings::SOUND_SOURCES.len()
|
||||
&& position_ok
|
||||
&& gain_ok
|
||||
}
|
||||
|
||||
/// Whether a native mod's particles can be sent. Block particle types need
|
||||
/// a known state (`state_ok`); item, trail and vibration particles aren't
|
||||
/// supported by the network dispatch; a mod's own type must be namespaced.
|
||||
pub(super) fn mod_particles_ok(
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
state_ok: impl Fn(BlockStateId) -> bool,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
) -> bool {
|
||||
use ParticleType as T;
|
||||
// A native mod's own type: the name has to be namespaced, and a client
|
||||
// draws it only when its pack has the sprite set.
|
||||
if let Some(name) = &options.mod_particle {
|
||||
let namespaced = name
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
|
||||
if !namespaced {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let supported = match options.ty {
|
||||
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
|
||||
state.is_some_and(state_ok)
|
||||
}
|
||||
T::Item | T::Trail | T::Vibration => false,
|
||||
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
|
||||
.geyser
|
||||
.is_some_and(|(_, impulse)| impulse.is_finite()),
|
||||
_ => true,
|
||||
};
|
||||
supported
|
||||
&& at.into_iter().all(f64::is_finite)
|
||||
&& spread
|
||||
.into_iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
|
||||
&& speed.is_finite()
|
||||
&& speed.abs() <= f32::MAX as f64
|
||||
&& options.color.into_iter().all(f32::is_finite)
|
||||
&& options.to_color.into_iter().all(f32::is_finite)
|
||||
&& options.scale.is_finite()
|
||||
}
|
||||
|
||||
pub(super) fn sound_id(id: &str) -> bool {
|
||||
let Some((namespace, path)) = id.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
let allowed = |c: char, slash: bool| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || "_.-".contains(c) || (slash && c == '/')
|
||||
};
|
||||
!namespace.is_empty()
|
||||
&& !path.is_empty()
|
||||
&& namespace.chars().all(|c| allowed(c, false))
|
||||
&& path.chars().all(|c| allowed(c, true))
|
||||
}
|
||||
@@ -560,6 +560,34 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
|
||||
/// changes as `(pos, previous, viewers)`, in position order. A container that
|
||||
/// is no longer counted goes to 0 and leaves `known`.
|
||||
pub(super) fn viewer_changes(
|
||||
known: &mut std::collections::HashMap<(&'static str, (i32, i32, i32)), u32>,
|
||||
dim: &'static str,
|
||||
mut now: std::collections::HashMap<(i32, i32, i32), u32>,
|
||||
) -> Vec<((i32, i32, i32), u32, u32)> {
|
||||
let mut changes = Vec::new();
|
||||
known.retain(|&(d, pos), previous| {
|
||||
if d != dim {
|
||||
return true;
|
||||
}
|
||||
let viewers = now.remove(&pos).unwrap_or(0);
|
||||
if viewers != *previous {
|
||||
changes.push((pos, *previous, viewers));
|
||||
*previous = viewers;
|
||||
}
|
||||
viewers > 0
|
||||
});
|
||||
for (pos, viewers) in now {
|
||||
known.insert((dim, pos), viewers);
|
||||
changes.push((pos, 0, viewers));
|
||||
}
|
||||
changes.sort_unstable();
|
||||
changes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -208,6 +208,9 @@ fn not_drawn(m: &Manifest, switch: bool) -> Option<String> {
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if m.entity.is_some() {
|
||||
return Some("the [entity] stage isn't drawn yet: leave it out".into());
|
||||
}
|
||||
(m.api != Api::V0_1 && !switch).then(|| {
|
||||
format!(
|
||||
"it is written for shader API {}, which is experimental and drawn only under \
|
||||
@@ -359,6 +362,8 @@ mod tests {
|
||||
why.contains("primaries = \"bt2020\" isn't served yet"),
|
||||
"{why}"
|
||||
);
|
||||
let why = not_drawn(&manifest("0.2", "[entity]\nfile = \"e.wgsl\"\n"), switch);
|
||||
assert!(why.unwrap().contains("[entity] stage isn't drawn yet"));
|
||||
let named = manifest("0.2", "[frame]\nprimaries = \"bt709\"\n");
|
||||
assert_eq!(not_drawn(&named, true), None);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use super::entity::{Entity as _, entity_lists_of, for_each_entity, with_entity};
|
||||
use super::{Dim, Game, Result, Screen, level_flags};
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_client::world::PendingEntity;
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Axis, Vec3},
|
||||
position::BlockPos,
|
||||
@@ -74,10 +75,22 @@ impl Plan {
|
||||
/// An entity on its way to a dimension that isn't loaded.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Arrival {
|
||||
/// Teleport-only state; deliberately absent from arrival_tag.
|
||||
tnt_used_portal: bool,
|
||||
plan: Plan,
|
||||
tag: Tag,
|
||||
}
|
||||
|
||||
impl Arrival {
|
||||
fn new(plan: Plan, tag: Tag, tnt_used_portal: bool) -> Self {
|
||||
Self {
|
||||
plan,
|
||||
tag,
|
||||
tnt_used_portal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The nether portal state of the world (`Game::portals`).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Portals {
|
||||
@@ -257,6 +270,7 @@ fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
|
||||
return None;
|
||||
};
|
||||
Some(Arrival {
|
||||
tnt_used_portal: false,
|
||||
plan: Plan {
|
||||
dim,
|
||||
approximate: BlockPos::new(x, y, z),
|
||||
@@ -531,6 +545,7 @@ impl Game<'_> {
|
||||
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) {
|
||||
@@ -1098,7 +1113,7 @@ impl Game<'_> {
|
||||
);
|
||||
self.remove_dropped(i);
|
||||
self.detach_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.arrivals.push(Arrival::new(plan, tag, false));
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.tnts.iter().position(|t| t.id == id) {
|
||||
let t = &self.tnts[i];
|
||||
@@ -1106,10 +1121,13 @@ impl Game<'_> {
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
self.tnts[i].used_portal = true;
|
||||
let tag = self.tnt_nbt(i);
|
||||
self.tnts.remove(i);
|
||||
let transferred = self.tnts.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals
|
||||
.arrivals
|
||||
.push(Arrival::new(plan, tag, transferred.used_portal));
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.tridents.iter().position(|t| t.id == id) {
|
||||
let plan = self.entity_plan(entry, self.tridents[i].position, 0.5, 0.5, false);
|
||||
@@ -1121,7 +1139,7 @@ impl Game<'_> {
|
||||
self.tridents[i].to_nbt(uuid, self.pack.assets.registry, self.persist_raw(id));
|
||||
self.tridents.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.arrivals.push(Arrival::new(plan, tag, false));
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.rockets.iter().position(|r| r.id == id) {
|
||||
let plan = self.entity_plan(entry, self.rockets[i].position, 0.25, 0.25, false);
|
||||
@@ -1132,7 +1150,7 @@ impl Game<'_> {
|
||||
let tag = self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist_raw(id));
|
||||
self.rockets.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.arrivals.push(Arrival::new(plan, tag, false));
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.orbs.iter().position(|o| o.id == id) {
|
||||
let plan = self.entity_plan(entry, self.orbs[i].position, 0.5, 0.5, false);
|
||||
@@ -1143,7 +1161,7 @@ impl Game<'_> {
|
||||
let tag = horizon_client::entity_nbt::orb_to_nbt(&self.orbs[i], uuid);
|
||||
self.orbs.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.arrivals.push(Arrival::new(plan, tag, false));
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let m = &self.mobs[i];
|
||||
@@ -1162,7 +1180,7 @@ impl Game<'_> {
|
||||
for g in gone {
|
||||
self.detach_entity(g);
|
||||
}
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.arrivals.push(Arrival::new(plan, tag, false));
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = &self.boats[i];
|
||||
@@ -1182,7 +1200,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.boats.remove(i);
|
||||
self.detach_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.arrivals.push(Arrival::new(plan, tag, false));
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let c = &self.carts[i];
|
||||
@@ -1213,7 +1231,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.carts.remove(i);
|
||||
self.detach_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.arrivals.push(Arrival::new(plan, tag, false));
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self
|
||||
.creatures
|
||||
@@ -1248,7 +1266,7 @@ impl Game<'_> {
|
||||
};
|
||||
self.creatures.projectiles.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.arrivals.push(Arrival::new(plan, tag, false));
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
if self.portals.arrivals.len() > arrivals {
|
||||
@@ -1323,7 +1341,11 @@ impl Game<'_> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
|
||||
let Arrival {
|
||||
plan,
|
||||
mut tag,
|
||||
tnt_used_portal,
|
||||
} = self.portals.arrivals.remove(i);
|
||||
self.portals.dirty = true;
|
||||
let motion = horizon_client::entity_nbt::motion(&tag);
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
|
||||
@@ -1359,6 +1381,11 @@ impl Game<'_> {
|
||||
preserve_arrival_cooldown(&mut tag);
|
||||
// ServerLevel.addDuringTeleport: a fresh add, not loaded from disk.
|
||||
super::persist::mark_fresh(&mut tag);
|
||||
let tag = if tnt_used_portal {
|
||||
PendingEntity::after_tnt_portal(tag)
|
||||
} else {
|
||||
tag.into()
|
||||
};
|
||||
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
match self
|
||||
.world
|
||||
@@ -1423,7 +1450,7 @@ impl Game<'_> {
|
||||
if f.id != 0 {
|
||||
self.forget_entity(f.id);
|
||||
}
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.arrivals.push(Arrival::new(plan, tag, false));
|
||||
self.portals.dirty = true;
|
||||
Some(plan.dim)
|
||||
}
|
||||
@@ -1598,6 +1625,20 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn portal_transfer_pending_arrival_does_not_save_transient_tnt_immunity() {
|
||||
let arrival = Arrival {
|
||||
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.98, 0.98),
|
||||
tag: Tag::compound([("id", Tag::String("minecraft:tnt".into()))]),
|
||||
tnt_used_portal: true,
|
||||
};
|
||||
assert!(
|
||||
!arrival_from_tag(&arrival_tag(&arrival))
|
||||
.unwrap()
|
||||
.tnt_used_portal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
|
||||
let spawn = BlockPos::new(-7, 70, 12);
|
||||
@@ -1677,6 +1718,7 @@ mod tests {
|
||||
let arrival = Arrival {
|
||||
plan: Plan::end_portal(Dim::Overworld, BlockPos::new(0, 64, 0), 1.375, 0.5625),
|
||||
tag,
|
||||
tnt_used_portal: false,
|
||||
};
|
||||
let restored = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
|
||||
let expected = old.filter(|n| *n > 0).unwrap_or(ENTITY_DELAY);
|
||||
@@ -1692,6 +1734,7 @@ mod tests {
|
||||
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
|
||||
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
|
||||
let arrival = Arrival {
|
||||
tnt_used_portal: false,
|
||||
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
|
||||
tag: tag.clone(),
|
||||
};
|
||||
@@ -1703,6 +1746,7 @@ mod tests {
|
||||
}
|
||||
// Arrivals saved before End portals were Nether portal plans.
|
||||
let mut old = arrival_tag(&Arrival {
|
||||
tnt_used_portal: false,
|
||||
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
|
||||
tag,
|
||||
});
|
||||
|
||||
@@ -126,6 +126,8 @@ impl Game<'_> {
|
||||
.map(super::native_hooks::mob_kind)
|
||||
.unwrap_or_default();
|
||||
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
|
||||
// Animal.setInLove: entity event 18, the love hearts.
|
||||
self.mob_event_particles(id, 18);
|
||||
}
|
||||
self.consume_held();
|
||||
if let Some(stack) = dropped {
|
||||
|
||||
@@ -7,6 +7,7 @@ mod display;
|
||||
mod hurt_by_refs;
|
||||
|
||||
use super::{FallingBlock, Game};
|
||||
use horizon_client::world::EntityLoadReason;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
chunk::Chunk,
|
||||
@@ -534,7 +535,7 @@ impl Game<'_> {
|
||||
let player_uuid = self.level.player_uuid;
|
||||
for (key, tags) in loaded {
|
||||
let count = tags.len();
|
||||
for mut tag in tags {
|
||||
for (mut tag, reason) in tags.into_iter().map(|pending| pending.into_parts()) {
|
||||
// A world older builds named by the offline profile names the player that
|
||||
// way in its entities too: migrate them as they load, so the next save of
|
||||
// the chunk keeps the account's UUID (`playerdata::migrate_owner`).
|
||||
@@ -591,7 +592,16 @@ impl Game<'_> {
|
||||
}
|
||||
// Kinds outside `entity_lists!` first, then the listed kinds
|
||||
// (`Entity::load_nbt`), then whatever else is a compound.
|
||||
if let Err(tag) = self.load_unlisted(key, &kind, uuid, tag)
|
||||
let loaded = if reason == EntityLoadReason::TeleportedTnt
|
||||
&& kind == "minecraft:tnt"
|
||||
&& !is_mob
|
||||
{
|
||||
self.load_tnt_with_portal(tag, uuid, true);
|
||||
Ok(())
|
||||
} else {
|
||||
self.load_unlisted(key, &kind, uuid, tag)
|
||||
};
|
||||
if let Err(tag) = loaded
|
||||
&& let Err(tag) = self.load_listed(&kind, uuid, player_uuid, tag)
|
||||
{
|
||||
if !kind.is_empty() && entity_nbt::position(&tag).is_some() {
|
||||
@@ -852,6 +862,9 @@ impl Game<'_> {
|
||||
/// `PrimedTnt.readAdditionalSaveData`: its numeric short fuse (80 when absent) and motion.
|
||||
// Its Passengers ride it (EntityType.loadEntityRecursive forces them on).
|
||||
pub(super) fn load_tnt(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
|
||||
self.load_tnt_with_portal(tag, uuid, false);
|
||||
}
|
||||
fn load_tnt_with_portal(&mut self, tag: Tag, uuid: Option<[i32; 4]>, used_portal: bool) {
|
||||
if uuid.is_some_and(|u| self.target_with_uuid(u).is_some()) {
|
||||
return;
|
||||
}
|
||||
@@ -868,6 +881,7 @@ impl Game<'_> {
|
||||
// to int before short narrowing; integer tags narrow directly.
|
||||
let fuse = tnt_fuse(&tag);
|
||||
self.tnts.push(super::tnt::PrimedTnt {
|
||||
used_portal,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: entity_nbt::flag(&tag, "NoGravity"),
|
||||
@@ -1370,7 +1384,7 @@ impl Game<'_> {
|
||||
{
|
||||
entity_nbt::fill_common_defaults(&mut tag);
|
||||
}
|
||||
tag
|
||||
with_raw(tag, None) // Entity.saveWithoutId's flags, as booleans.
|
||||
}
|
||||
/// The root of a stack of riders (`Entity.getRootVehicle`).
|
||||
fn mob_root(&self, mut i: usize) -> usize {
|
||||
@@ -1500,7 +1514,7 @@ impl Game<'_> {
|
||||
(at.z.floor() as i32).div_euclid(16),
|
||||
);
|
||||
self.persist.restored.insert(root_uuid);
|
||||
self.world.loaded_entities.push((key, vec![tag]));
|
||||
self.world.queue_entity_nbt(key, vec![tag]);
|
||||
self.spawn_loaded_entities();
|
||||
}
|
||||
self.persist.restored.insert(uuid);
|
||||
@@ -2998,7 +3012,7 @@ mod tests {
|
||||
}
|
||||
// The level is a server's, whose attachments a mod reaches.
|
||||
game.options.dedicated = Some(Vec::new());
|
||||
game.world.loaded_entities.push(((0, 0), tags));
|
||||
game.world.queue_entity_nbt((0, 0), tags);
|
||||
game.spawn_loaded_entities();
|
||||
let saved = game.entity_chunks(&HashSet::from([(0, 0)]), false);
|
||||
let saved: Vec<&Tag> = saved.iter().flat_map(|(_, tags)| tags).collect();
|
||||
@@ -3088,7 +3102,7 @@ mod tests {
|
||||
.filter(|c| c.potion.is_some())
|
||||
.count()
|
||||
};
|
||||
game.world.loaded_entities.push(((0, 0), vec![cloud]));
|
||||
game.world.queue_entity_nbt((0, 0), vec![cloud]);
|
||||
game.spawn_loaded_entities();
|
||||
assert_eq!(clouds(&game), 1, "the cloud loads");
|
||||
assert_eq!(game.breath_clouds[0].motion, [0.6, 0.0, 0.0]);
|
||||
@@ -3100,7 +3114,7 @@ mod tests {
|
||||
Vec3::new(0.6, 0.0, 0.0)
|
||||
);
|
||||
assert_eq!(clouds(&game), 0, "and takes it out of the level");
|
||||
game.world.loaded_entities.push(((0, 0), written));
|
||||
game.world.queue_entity_nbt((0, 0), written);
|
||||
game.spawn_loaded_entities();
|
||||
assert_eq!(clouds(&game), 1, "loading the chunk again gives one cloud");
|
||||
assert_eq!(game.breath_clouds[0].motion, [0.6, 0.0, 0.0]);
|
||||
@@ -4258,6 +4272,7 @@ mod attribute_instance_save_tests {
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
test(&mut game);
|
||||
}
|
||||
fn mob(game: &Game<'_>, n: i32) -> usize {
|
||||
@@ -5276,6 +5291,7 @@ mod h262_enderman_absolute_anger_tests {
|
||||
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;262,0,0,99],NoAI:1b,NoGravity:1b}")
|
||||
);
|
||||
game.world.random.set_seed(262);
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
test(&mut game);
|
||||
}
|
||||
fn load(game: &mut Game<'_>, n: i32, end: Option<i64>) -> (u32, usize) {
|
||||
@@ -5511,6 +5527,7 @@ mod sulfur_body_attribute_tests {
|
||||
assert!(game.run_command("scoreboard objectives add h192 dummy"));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
test(&mut game);
|
||||
}
|
||||
/// `/attribute <cube> <attribute> get 1000`, read back through a score.
|
||||
@@ -5536,9 +5553,9 @@ mod sulfur_body_attribute_tests {
|
||||
(2, ""),
|
||||
] {
|
||||
assert!(game.run_command(&format!(
|
||||
"summon minecraft:sulfur_cube {}.5 200 4.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
|
||||
3 * n
|
||||
)));
|
||||
"summon minecraft:sulfur_cube {}.5 200 4.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
|
||||
3 * n
|
||||
)));
|
||||
}
|
||||
let attrs = [
|
||||
"knockback_resistance",
|
||||
@@ -5582,8 +5599,8 @@ mod sulfur_body_attribute_tests {
|
||||
fn a_bodiless_cube_keeps_its_command_knockback_resistance_effect() {
|
||||
with_game(|game| {
|
||||
assert!(game.run_command(
|
||||
"summon minecraft:sulfur_cube 4.5 200 8.5 {UUID:[I;192,0,0,9],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1}"
|
||||
));
|
||||
"summon minecraft:sulfur_cube 4.5 200 8.5 {UUID:[I;192,0,0,9],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1}"
|
||||
));
|
||||
let sel = "@e[type=minecraft:sulfur_cube,nbt={UUID:[I;192,0,0,9]},limit=1]";
|
||||
let cached = |game: &Game<'_>| {
|
||||
let m = game
|
||||
@@ -5650,9 +5667,9 @@ mod sulfur_body_attribute_tests {
|
||||
(11, ",equipment:{body:{id:\"minecraft:oak_planks\"}}"),
|
||||
] {
|
||||
assert!(game.run_command(&format!(
|
||||
"summon minecraft:sulfur_cube {}.5 200 8.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
|
||||
n - 8
|
||||
)));
|
||||
"summon minecraft:sulfur_cube {}.5 200 8.5 {{UUID:[I;192,0,0,{n}],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b,Size:1{body}}}",
|
||||
n - 8
|
||||
)));
|
||||
}
|
||||
game.tick_world(true).unwrap();
|
||||
let cached = |game: &Game<'_>, n: i32| {
|
||||
@@ -5765,6 +5782,7 @@ mod summoned_knot_tests {
|
||||
game.run_command("summon pig 13.5 200 2.5 {UUID:[I;129,0,0,99],NoAI:1b,NoGravity:1b}")
|
||||
);
|
||||
game.world.random.set_seed(129);
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
test(&mut game);
|
||||
}
|
||||
fn knot_id(game: &Game<'_>, n: i32) -> u32 {
|
||||
@@ -5895,12 +5913,8 @@ mod summoned_knot_tests {
|
||||
#[ignore = "prepared resources; negative fractional registered position"]
|
||||
fn negative_fractional_knot_position_uses_floor_before_centering() {
|
||||
with_game(|game| {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((-1, -1), Arc::new(Chunk::empty(-1, -1)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(-1, -1))
|
||||
.unwrap();
|
||||
// Chunk (-1, -1) is already loaded by the fixture's neighbourhood.
|
||||
assert!(game.world.chunks.contains_key(&(-1, -1)));
|
||||
assert!(game.run_command("setblock -2 199 -3 stone strict"));
|
||||
assert!(game.run_command("setblock -2 200 -3 oak_fence strict"));
|
||||
assert!(game.run_command("summon leash_knot -1.2 200.8 -2.1 {UUID:[I;129,0,0,5]}"));
|
||||
@@ -6127,7 +6141,9 @@ mod camel_pose_saved_tests {
|
||||
.into_iter()
|
||||
.flat_map(|(_, tags)| tags)
|
||||
.collect();
|
||||
game.world.loaded_entities.push(((0, 0), written));
|
||||
game.world
|
||||
.loaded_entities
|
||||
.push(((0, 0), written.into_iter().map(Into::into).collect()));
|
||||
game.spawn_loaded_entities();
|
||||
for step in 0..8 {
|
||||
for number in 1..=cases.len() as i32 {
|
||||
|
||||
@@ -1498,6 +1498,7 @@ mod h255_mob_piston_ground_tests {
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
|
||||
|
||||
@@ -112,19 +112,21 @@ impl Game<'_> {
|
||||
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
|
||||
equipped.push((self.offhand.as_ref(), "offhand"));
|
||||
for (stack, slot) in equipped {
|
||||
// ItemStack.forEachModifier: the stack's own ATTRIBUTE_MODIFIERS
|
||||
// (its item's unless a command or a mod replaced them, #14641).
|
||||
let Some(stack) = stack else {
|
||||
continue;
|
||||
};
|
||||
let modifiers = world_items!(self).attribute_modifiers(stack);
|
||||
for (attribute, id, amount, op, group) in &modifiers {
|
||||
if !equipment_modifier_applies(group, slot) {
|
||||
for (attribute, id, amount, op, group) in world_items!(self).attribute_modifiers(stack)
|
||||
{
|
||||
if !equipment_modifier_applies(&group, slot) {
|
||||
continue;
|
||||
}
|
||||
if player
|
||||
.set_modifier_unclamped(attribute, id, *amount, op, true)
|
||||
.set_modifier_unclamped(&attribute, &id, amount, &op, true)
|
||||
.is_ok()
|
||||
{
|
||||
self.applied_modifiers.push((attribute.clone(), id.clone()));
|
||||
self.applied_modifiers.push((attribute, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ fn mobs_past_the_simulation_distance_neither_enter_portals_nor_cool_down() {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, 0), Arc::new(Chunk::empty(cx, 0)));
|
||||
game.load_ticking_neighbourhood(cx, 0);
|
||||
for z in [8, 12] {
|
||||
game.world.set(x, 63, z, stone).unwrap();
|
||||
}
|
||||
|
||||
@@ -385,31 +385,21 @@ impl Game<'_> {
|
||||
.play("minecraft:entity.silverfish.hurt", Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// A splash potion, lingering potion or experience bottle in flight (the
|
||||
/// player's throws and dispensers both end up here).
|
||||
pub(super) fn launch_thrown(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
position: Vec3,
|
||||
velocity: Vec3,
|
||||
owner: Option<Text>,
|
||||
) {
|
||||
let id = self.take_entity_id();
|
||||
self.potions.thrown.push(ThrownPotion {
|
||||
id,
|
||||
stack,
|
||||
previous: position,
|
||||
position,
|
||||
velocity,
|
||||
age: 0,
|
||||
thrower: owner.is_some().then_some(Who::Player),
|
||||
guest: self.lan_acting.filter(|_| owner.is_some()),
|
||||
owner,
|
||||
left_owner: false,
|
||||
last_deflected_by: None,
|
||||
random: LegacyRandom::new(i64::from(id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// A splash potion, lingering potion or experience bottle about to fly (the
|
||||
/// player's throws and dispensers both make one): `ThrownSplashPotion`,
|
||||
/// `ThrownLingeringPotion` or `ThrownExperienceBottle`, which
|
||||
/// `level.addFreshEntity` makes a level entity like a summoned one, so
|
||||
/// selectors and `/data` find it (#13201).
|
||||
pub(super) fn thrown_projectile(stack: ItemStack, position: Vec3, by_player: bool) -> Projectile {
|
||||
let kind = match &*stack.item {
|
||||
"minecraft:experience_bottle" => ProjectileKind::ExperienceBottle,
|
||||
item => {
|
||||
let lingering = item == "minecraft:lingering_potion";
|
||||
ProjectileKind::Potion(Box::new(stack), lingering)
|
||||
}
|
||||
};
|
||||
Projectile::new(kind, position, by_player.then_some(Who::Player))
|
||||
}
|
||||
/// What a player's body was last given for its effects and enchantments:
|
||||
/// a guest keeps their own while the host's are in `PotionState`.
|
||||
@@ -586,43 +576,21 @@ impl Game<'_> {
|
||||
_ => ("minecraft:entity.lingering_potion.throw", 0.5),
|
||||
};
|
||||
self.audio.play(sound, Some(eye), 0.5, pitch);
|
||||
// Projectile.shootFromRotation(xRot, yRot, -20, 0.5, 1.0).
|
||||
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
|
||||
let dir = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-(self.pitch - 20.0).to_radians().sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let l = dir
|
||||
.iter()
|
||||
.map(|c| f64::from(*c).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
.max(1e-9);
|
||||
// getMovementToShoot: normalize, add triangle(0, 0.0172275 * uncertainty), scale.
|
||||
let r = &mut self.world.random;
|
||||
let mut jitter = || r.triangle(0.0, 0.0172275);
|
||||
let v = Vec3::new(
|
||||
(f64::from(dir[0]) / l + jitter()) * power,
|
||||
(f64::from(dir[1]) / l + jitter()) * power,
|
||||
(f64::from(dir[2]) / l + jitter()) * power,
|
||||
);
|
||||
let pv = self.known_player_movement();
|
||||
// Projectile.shootFromRotation(xRot, yRot, -20, power, 1.0), with
|
||||
// what the thrower last moved (getKnownMovement).
|
||||
let velocity = self.known_player_movement();
|
||||
let on_ground = self.player.as_ref().is_some_and(|p| p.ground.on_ground);
|
||||
let v = Vec3::new(
|
||||
v.x + pv.x,
|
||||
v.y + if on_ground { 0.0 } else { pv.y },
|
||||
v.z + pv.z,
|
||||
);
|
||||
self.launch_thrown(
|
||||
ItemStack {
|
||||
count: 1,
|
||||
..stack.clone()
|
||||
},
|
||||
Vec3::new(eye[0], eye[1] - 0.1, eye[2]),
|
||||
v,
|
||||
Some(self.player_display_text()),
|
||||
let one = ItemStack {
|
||||
count: 1,
|
||||
..stack.clone()
|
||||
};
|
||||
let mut shot =
|
||||
thrown_projectile(one, Vec3::new(eye[0], eye[1] - 0.1, eye[2]), true);
|
||||
shot.shoot_from_rotation(
|
||||
self.pitch, self.yaw, -20.0, power, 1.0, velocity, on_ground,
|
||||
);
|
||||
shot.guest = self.lan_acting;
|
||||
self.add_projectile(shot);
|
||||
// ThrowablePotionItem.use / ExperienceBottleItem.use: awardStat(ITEM_USED).
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
@@ -2667,6 +2635,7 @@ mod mob_absorption_max_tests {
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
assert!(game.run_command("scoreboard objectives add h203 dummy"));
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
test(&mut game);
|
||||
}
|
||||
fn pig(game: &Game<'_>, n: i32) -> usize {
|
||||
@@ -2836,6 +2805,7 @@ mod mob_health_boost_tests {
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
test(&mut game);
|
||||
}
|
||||
fn pig(game: &Game<'_>, n: i32) -> usize {
|
||||
@@ -3081,6 +3051,7 @@ mod depleted_absorption_tests {
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
test(&mut game);
|
||||
}
|
||||
/// The mob in the level with UUID word `n`.
|
||||
|
||||
@@ -3029,11 +3029,12 @@ impl Game<'_> {
|
||||
d.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
// A newly spawned block or TNT is at the back of its list.
|
||||
if let Some(f) = self.falling.iter_mut().rev().find(|f| f.id == id as u32) {
|
||||
f.position = at;
|
||||
return;
|
||||
}
|
||||
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
|
||||
if let Some(t) = self.tnts.iter_mut().rev().find(|t| t.id == id as u32) {
|
||||
t.position = at;
|
||||
t.on_ground = on_ground;
|
||||
return;
|
||||
@@ -3409,20 +3410,7 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
C::PlayerPosition(p) => self.remote_teleport(&p)?,
|
||||
C::PlayerRotation(p) => {
|
||||
self.yaw = if p.relative_yaw {
|
||||
self.yaw + p.yaw
|
||||
} else {
|
||||
p.yaw
|
||||
};
|
||||
self.pitch = (if p.relative_pitch {
|
||||
self.pitch + p.pitch
|
||||
} else {
|
||||
p.pitch
|
||||
})
|
||||
.clamp(-90.0, 90.0);
|
||||
self.snap_player_model_pitch();
|
||||
}
|
||||
C::PlayerRotation(p) => self.remote_rotation(&p),
|
||||
C::PlayerLookAt(p) => self.remote_look_at(&p),
|
||||
// ClientCommonPacketListenerImpl.handlePostEffects.
|
||||
C::PostEffects(p) => self.post_effects = p.effects,
|
||||
@@ -3576,6 +3564,7 @@ impl Game<'_> {
|
||||
p.id as u32,
|
||||
horizon_client::lan::playerdata::uuid_ints(p.uuid),
|
||||
);
|
||||
let replace = self.tracks_remote_entity(p.id);
|
||||
self.spawn_entity(p.id, &kind, p.position, rotation);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.cart_lerps.remove(&p.id);
|
||||
@@ -3588,14 +3577,7 @@ impl Game<'_> {
|
||||
(rotation[0], rotation[1]),
|
||||
p.data,
|
||||
) {
|
||||
self.spawn_remote_projectile(
|
||||
p.id,
|
||||
&kind,
|
||||
p.position,
|
||||
(rotation[0], rotation[1]),
|
||||
p.velocity,
|
||||
p.data,
|
||||
);
|
||||
self.spawn_added_projectile(&p, &kind, (rotation[0], rotation[1]), replace);
|
||||
}
|
||||
// Entity.recreateFromPacket: objects must exist before applying motion.
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
@@ -5898,14 +5880,16 @@ impl Game<'_> {
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
self.remote_dragon_motion(id, velocity);
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
// `falling` and `tnts` are searched from the back: AddEntity calls this right after
|
||||
// appending the block or TNT, so a spawn doesn't walk the whole list.
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
|
||||
// Squid.aiStep derives its body pitch and roll from network motion.
|
||||
m.velocity = v;
|
||||
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.velocity = v;
|
||||
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
} else if let Some(f) = self.falling.iter_mut().rev().find(|f| f.id == id as u32) {
|
||||
f.velocity = v;
|
||||
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
|
||||
} else if let Some(t) = self.tnts.iter_mut().rev().find(|t| t.id == id as u32) {
|
||||
t.velocity = v;
|
||||
} else if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id as u32) {
|
||||
o.velocity = v;
|
||||
|
||||
@@ -34,6 +34,29 @@ impl Game<'_> {
|
||||
pub(in crate::game) fn remote_walking_speed(&self) -> f32 {
|
||||
walking_speed(self.remote.as_ref().and_then(|r| r.abilities.as_ref()))
|
||||
}
|
||||
|
||||
/// ClientPacketListener.handleRotatePlayer: the server's yaw and pitch, each added to the
|
||||
/// current one when its bit says relative, then sent back as a `Rot` move with both
|
||||
/// flags false.
|
||||
pub(super) fn remote_rotation(&mut self, p: &play::PlayerRotation) {
|
||||
self.yaw = if p.relative_yaw {
|
||||
self.yaw + p.yaw
|
||||
} else {
|
||||
p.yaw
|
||||
};
|
||||
self.pitch = (if p.relative_pitch {
|
||||
self.pitch + p.pitch
|
||||
} else {
|
||||
p.pitch
|
||||
})
|
||||
.clamp(-90.0, 90.0);
|
||||
self.snap_player_model_pitch();
|
||||
self.send(S::MovePlayerRot(play::MovePlayerRot {
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
flags: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -132,6 +132,14 @@ impl Game<'_> {
|
||||
.unwrap_or_default();
|
||||
nautilus_takes(nautilus::is_food(m, item, &taming), m.extra.tame, baby)
|
||||
}
|
||||
// Dolphin.mobInteract: a #minecraft:fishes item is SUCCESS on both
|
||||
// sides (the server feeds it), so the client swings and keeps the fish.
|
||||
Species::Dolphin => self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:fishes")
|
||||
.is_some_and(|t| t.contains(item)),
|
||||
// IronGolem.mobInteract.
|
||||
Species::IronGolem => iron_golem_takes(item, m.health, m.attrs.max_health),
|
||||
// CopperGolem.mobInteract.
|
||||
@@ -254,7 +262,15 @@ fn copper_golem_takes(item: &str, holding: bool, shearable: bool) -> bool {
|
||||
/// `handleEating` gates both on `isBaby() && !isAgeLocked()` — a locked baby
|
||||
/// does not grow, and the particle goes with the growth.
|
||||
pub(super) fn ages_on_food(m: &Mob, item: &str) -> bool {
|
||||
if !m.is_baby() || m.extra.age_locked || item.is_empty() {
|
||||
if m.extra.age_locked || item.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// Tadpole.feed draws the same particle on both sides; a tadpole is never
|
||||
// `isBaby`, its age counts up to the frog.
|
||||
if m.species() == Species::Tadpole {
|
||||
return m.kind.food.iter().any(|f| f.as_str() == item);
|
||||
}
|
||||
if !m.is_baby() {
|
||||
return false;
|
||||
}
|
||||
match m.species() {
|
||||
@@ -494,4 +510,18 @@ mod tests {
|
||||
assert!(!ages_on_food(&camel, "minecraft:cactus"), "locked camel");
|
||||
}
|
||||
}
|
||||
|
||||
/// `Tadpole.feed` draws its particle on the client too, though a tadpole
|
||||
/// is never a baby; an age-locked one is not fed (#8554).
|
||||
#[test]
|
||||
fn a_tadpole_fed_a_slime_ball_draws_happy_villager() {
|
||||
let Some(mut tadpole) = mob("minecraft:tadpole") else {
|
||||
return;
|
||||
};
|
||||
assert!(!tadpole.is_baby());
|
||||
assert!(ages_on_food(&tadpole, "minecraft:slime_ball"));
|
||||
assert!(!ages_on_food(&tadpole, "minecraft:wheat"), "not frog food");
|
||||
tadpole.extra.age_locked = true;
|
||||
assert!(!ages_on_food(&tadpole, "minecraft:slime_ball"), "locked");
|
||||
}
|
||||
}
|
||||
@@ -316,15 +316,58 @@ impl Game<'_> {
|
||||
rotation: (f32, f32),
|
||||
velocity: [f64; 3],
|
||||
data: i32,
|
||||
) -> bool {
|
||||
self.remove_remote_projectile(id as u32);
|
||||
self.spawn_remote_projectile_inner(id, kind, position, rotation, velocity, data)
|
||||
}
|
||||
|
||||
/// Whether the client tracks `id` already: asked by `AddEntity` before it tracks the new one.
|
||||
pub(super) fn tracks_remote_entity(&self, id: i32) -> bool {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.tracked_entity(id).is_some())
|
||||
}
|
||||
|
||||
/// `AddEntity`'s `spawn_remote_projectile`. Only an id the client already tracked (`replace`)
|
||||
/// can still be in `falling` or `tnts`, so a new id skips those passes; the crystals and the
|
||||
/// small lists are filtered every time, since the client adds to some of those itself.
|
||||
pub(super) fn spawn_added_projectile(
|
||||
&mut self,
|
||||
p: &horizon_protocol::packets::play::AddEntity,
|
||||
kind: &str,
|
||||
rotation: (f32, f32),
|
||||
replace: bool,
|
||||
) {
|
||||
if replace {
|
||||
self.spawn_remote_projectile(p.id, kind, p.position, rotation, p.velocity, p.data);
|
||||
} else {
|
||||
self.crystals.retain(|c| c.id != p.id as u32);
|
||||
self.remove_remote_small_projectiles(p.id as u32);
|
||||
self.spawn_remote_projectile_inner(
|
||||
p.id, kind, p.position, rotation, p.velocity, p.data,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `spawn_remote_projectile` without first removing an entity under the same id: for
|
||||
/// `AddEntity` of an id the client does not track yet, which no list holds.
|
||||
pub(super) fn spawn_remote_projectile_inner(
|
||||
&mut self,
|
||||
id: i32,
|
||||
kind: &str,
|
||||
position: [f64; 3],
|
||||
rotation: (f32, f32),
|
||||
velocity: [f64; 3],
|
||||
data: i32,
|
||||
) -> bool {
|
||||
let id = id as u32;
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
let (yaw, pitch) = rotation;
|
||||
self.remove_remote_projectile(id);
|
||||
match kind {
|
||||
"minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0),
|
||||
"minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt {
|
||||
used_portal: false,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -400,14 +443,8 @@ impl Game<'_> {
|
||||
.next_int_bound(100_000)
|
||||
.unwrap_or(0);
|
||||
let time = super::dragon::Crystal::start_time(at, drawn, self.clock.still());
|
||||
self.crystals.push(super::dragon::Crystal {
|
||||
id,
|
||||
position: at,
|
||||
show_bottom: true,
|
||||
time,
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
self.crystals
|
||||
.push(super::dragon::Crystal::new(id, at, true, time));
|
||||
}
|
||||
_ => {
|
||||
let Some(k) = projectile_kind(kind) else {
|
||||
|
||||
@@ -183,6 +183,93 @@ mod tests {
|
||||
assert_eq!(game.falling.iter().map(|e| e.id).collect::<Vec<_>>(), [45]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn add_entity_replaces_falling_block_without_removing_other_ids() {
|
||||
use crate::game::{
|
||||
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = ExecutionRole::Client;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (client, _server) = horizon_protocol::connection::Pipe::pair();
|
||||
game.connect_local(client, "SpawnTest").unwrap();
|
||||
let kind = game
|
||||
.remote
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.builtin
|
||||
.get("minecraft:entity_type")
|
||||
.unwrap()
|
||||
.id("minecraft:falling_block")
|
||||
.unwrap();
|
||||
for (id, position) in [
|
||||
(40, [1.0, 64.0, 2.0]),
|
||||
(41, [3.0, 65.0, 4.0]),
|
||||
(40, [5.0, 66.0, 6.0]),
|
||||
(42, [7.0, 67.0, 8.0]),
|
||||
] {
|
||||
game.handle_packet(C::AddEntity(play::AddEntity {
|
||||
id,
|
||||
uuid: id as u128,
|
||||
kind,
|
||||
position,
|
||||
velocity: [0.0, -0.1, 0.0],
|
||||
pitch: 0,
|
||||
yaw: 0,
|
||||
head_yaw: 0,
|
||||
data: 0,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(game.falling.iter().filter(|f| f.id == id as u32).count(), 1);
|
||||
let block = game.falling.iter().find(|f| f.id == id as u32).unwrap();
|
||||
assert_eq!(
|
||||
[block.position.x, block.position.y, block.position.z],
|
||||
position
|
||||
);
|
||||
assert_eq!(block.velocity.y, -0.1);
|
||||
}
|
||||
let blocks: Vec<_> = game
|
||||
.falling
|
||||
.iter()
|
||||
.map(|f| (f.id, [f.position.x, f.position.y, f.position.z]))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
blocks,
|
||||
[
|
||||
(41, [3.0, 65.0, 4.0]),
|
||||
(40, [5.0, 66.0, 6.0]),
|
||||
(42, [7.0, 67.0, 8.0]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn remove_entities_filters_mixed_entity_lists() {
|
||||
|
||||
@@ -67,6 +67,16 @@ impl Game<'_> {
|
||||
menu.set_slot(env, i, stack);
|
||||
Some(true)
|
||||
});
|
||||
if internal == super::stations::INPUT
|
||||
&& let Some(s) = self
|
||||
.station
|
||||
.as_mut()
|
||||
.filter(|s| s.kind == Kind::Stonecutter)
|
||||
{
|
||||
// The client menu's `Slot.set` on the input runs the screen's
|
||||
// listener (`StonecutterMenu.container.setChanged`).
|
||||
s.stonecutter_changed();
|
||||
}
|
||||
// AnvilScreen.slotChanged: the box shows the new input's name, which
|
||||
// its responder sends.
|
||||
self.remote_anvil_rename();
|
||||
|
||||
@@ -57,6 +57,9 @@ fn game() -> Game<'static> {
|
||||
.add_empty_chunk(ChunkPos::new(x, 0))
|
||||
.unwrap();
|
||||
}
|
||||
for x in 0..9 {
|
||||
game.load_ticking_neighbourhood(x, 0);
|
||||
}
|
||||
for command in [
|
||||
"forceload add 0 0 143 15",
|
||||
"tick freeze",
|
||||
|
||||
@@ -484,6 +484,37 @@ impl Game<'_> {
|
||||
.unwrap_or(self.ui.registries.permission)
|
||||
> 0
|
||||
}
|
||||
/// `EntityType.createDefaultStackConfig` on an entity an item made (a
|
||||
/// vehicle, a hanging entity, an armor stand): the item's custom name and
|
||||
/// custom data (`applyComponentsFromItemStack`:
|
||||
/// `Entity.applyImplicitComponent`'s two, saved as `CustomName` and
|
||||
/// `data`), then its `minecraft:entity_data` merged over the entity's own
|
||||
/// compound and loaded (`updateCustomEntityTag`) when it names this type.
|
||||
/// An op-only type (command block and spawner minecarts) takes it from an
|
||||
/// op player only, never from a dispenser (`by_player` false).
|
||||
pub(super) fn apply_item_stack_config(
|
||||
&mut self,
|
||||
target: super::commands::Target,
|
||||
kind: &str,
|
||||
stack: &horizon_client::items::ItemStack,
|
||||
by_player: bool,
|
||||
) {
|
||||
let op = by_player && self.acting_player_is_op();
|
||||
let configures = stack
|
||||
.data()
|
||||
.is_some_and(|d| d.custom_name.is_some() || d.other("minecraft:custom_data").is_some())
|
||||
|| horizon_client::block_entity_item::spawn_egg_entity_data(stack, kind, op).is_some();
|
||||
if !configures {
|
||||
return;
|
||||
}
|
||||
let Some(own) = self.entity_nbt(target) else {
|
||||
return;
|
||||
};
|
||||
let tag =
|
||||
horizon_client::block_entity_item::stack_configured_entity_by(stack, kind, &own, op);
|
||||
// The entity's own compound, its name and data merged in: it loads back.
|
||||
let _ = self.apply_entity_nbt(target, tag);
|
||||
}
|
||||
/// Ticks spawners, trial spawners and vaults near the player on a remote
|
||||
/// client (`tick_remote`).
|
||||
pub(super) fn tick_remote_spawners(&mut self) {
|
||||
|
||||
@@ -48,6 +48,7 @@ fn game() -> Game<'static> {
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.load_ticking_neighbourhood(0, 0);
|
||||
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
|
||||
game
|
||||
}
|
||||
|
||||
@@ -346,13 +346,10 @@ impl Station {
|
||||
pub(super) fn new_kind(kind: Kind, pos: [i32; 3]) -> Self {
|
||||
Self::new(kind, pos)
|
||||
}
|
||||
/// `StonecutterScreen.containerChanged`: without recipes to show, the
|
||||
/// list scrolls back to the top.
|
||||
/// `StonecutterScreen.containerChanged`: a changed input puts the list back at the top.
|
||||
pub(super) fn stonecutter_changed(&mut self) {
|
||||
if self.inputs[0].is_none() || self.stone.recipes.is_empty() {
|
||||
self.start = 0;
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
self.start = 0;
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
/// `StonecutterScreen.isScrollBarActive`.
|
||||
fn stonecutter_scrolls(&self) -> bool {
|
||||
@@ -2364,7 +2361,7 @@ impl Game<'_> {
|
||||
if display.shows("minecraft:attribute_modifiers") {
|
||||
// The item's own modifiers, then its enchantments' for each slot
|
||||
// group (`ItemStack.forEachModifier`).
|
||||
let mut modifiers = item_info.map(|i| i.modifiers.clone()).unwrap_or_default();
|
||||
let mut modifiers = self.items().attribute_modifiers(stack);
|
||||
modifiers.extend(e.modifier_rows(stack));
|
||||
let base = |name: &str, default| {
|
||||
self.player
|
||||
@@ -3157,3 +3154,6 @@ mod brewing_tick;
|
||||
|
||||
#[cfg(test)]
|
||||
mod brewing_changed_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod stonecutter_scroll_tests;
|
||||
@@ -0,0 +1,86 @@
|
||||
//! `StonecutterScreen.containerChanged` (`StonecutterScreen.java:205-209`): the
|
||||
//! recipe list goes back to the top whenever the menu's input container
|
||||
//! changes, and only then.
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_client::menu::Input;
|
||||
|
||||
fn deepslate(count: u32) -> ItemStack {
|
||||
ItemStack::new("minecraft:deepslate", count)
|
||||
}
|
||||
|
||||
/// The scroller at the end of its track: the last of the 17 deepslate
|
||||
/// recipes' rows (`startIndex` 8).
|
||||
fn scroll_to_bottom(game: &mut Game<'_>) {
|
||||
game.station_scroll(-2.0);
|
||||
let s = game.station.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(s.start, s.scroll),
|
||||
(8, 1.0),
|
||||
"the list is at its bottom row"
|
||||
);
|
||||
}
|
||||
|
||||
fn shown(game: &Game<'_>) -> (usize, f32) {
|
||||
let s = game.station.as_ref().unwrap();
|
||||
(s.start, s.scroll)
|
||||
}
|
||||
|
||||
fn pick_recipe(game: &mut Game<'_>, index: i32) -> bool {
|
||||
let mut taken = false;
|
||||
game.menu_session(|menu, env, _| {
|
||||
taken = menu.button(env, index);
|
||||
Some(true)
|
||||
});
|
||||
taken
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_stonecutter_list_returns_to_the_top_when_its_input_changes() {
|
||||
let mut g = server_game();
|
||||
assert!(g.open_station([0, 64, 0], "minecraft:stonecutter"));
|
||||
// Two deepslate in the input: 17 recipes, so the list scrolls.
|
||||
*g.slot_mut(9) = Some(deepslate(2));
|
||||
assert!(g.menu_click(Some(9), 0, Input::Pickup));
|
||||
assert!(g.menu_click(Some(INPUT), 0, Input::Pickup));
|
||||
let s = g.station.as_ref().unwrap();
|
||||
assert_eq!(s.inputs[0].as_ref().map(|i| i.count), Some(2));
|
||||
assert_eq!(
|
||||
s.stone.recipes.len(),
|
||||
17,
|
||||
"deepslate's stonecutting recipes"
|
||||
);
|
||||
|
||||
// Taking a result uses up one deepslate, so the input changes: the list is
|
||||
// back at the top.
|
||||
assert!(pick_recipe(&mut g, 8));
|
||||
scroll_to_bottom(&mut g);
|
||||
assert!(g.menu_click(Some(RESULT), 0, Input::Pickup));
|
||||
let s = g.station.as_ref().unwrap();
|
||||
assert_eq!(s.inputs[0].as_ref().map(|i| i.count), Some(1));
|
||||
assert_eq!(
|
||||
shown(&g),
|
||||
(0, 0.0),
|
||||
"taking a result left the list scrolled"
|
||||
);
|
||||
|
||||
// Adding the rest of the stack to the input: the same.
|
||||
*g.slot_mut(10) = Some(deepslate(1));
|
||||
scroll_to_bottom(&mut g);
|
||||
// A click in the player's inventory swaps the carried result for the
|
||||
// deepslate and does not touch the input: the list stays.
|
||||
assert!(g.menu_click(Some(10), 0, Input::Pickup));
|
||||
assert_eq!(g.carried, Some(deepslate(1)), "the swap took the deepslate");
|
||||
assert_eq!(shown(&g), (8, 1.0), "an inventory click scrolled the list");
|
||||
assert!(g.menu_click(Some(INPUT), 0, Input::Pickup));
|
||||
assert_eq!(g.carried, None, "the deepslate went into the input");
|
||||
let s = g.station.as_ref().unwrap();
|
||||
assert_eq!(s.inputs[0].as_ref().map(|i| i.count), Some(2));
|
||||
assert_eq!(shown(&g), (0, 0.0), "adding to the input left it scrolled");
|
||||
|
||||
// Picking a recipe does not touch the input either.
|
||||
scroll_to_bottom(&mut g);
|
||||
assert!(pick_recipe(&mut g, 0));
|
||||
assert_eq!(shown(&g), (8, 1.0), "picking a recipe scrolled the list");
|
||||
}
|
||||
@@ -660,10 +660,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.screen == Screen::Playing && self.replay.driver.is_some() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
self.screen == Screen::Playing && self.replay.driver.is_some()
|
||||
}
|
||||
|
||||
/// The free camera flies by the movement keys, once per frame.
|
||||
|
||||
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user