mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Compare commits
314
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5dbd2c5c1 | ||
|
|
4c4de96fa5 | ||
|
|
c19aaf98e5 | ||
|
|
30a7b86bc8 | ||
|
|
0e0f95efa6 | ||
|
|
483497ef1c | ||
|
|
f7ba023dc6 | ||
|
|
cad46c5ff5 | ||
|
|
654bed3a7f | ||
|
|
6154ce2282 | ||
|
|
5e331b277a | ||
|
|
96712da3cb | ||
|
|
755ba0b9a5 | ||
|
|
29223492fc | ||
|
|
d5f9e06dac | ||
|
|
26d4f3f273 | ||
|
|
fc71cbfeed | ||
|
|
0d55f3075b | ||
|
|
24b778c3b4 | ||
|
|
e1895229b8 | ||
|
|
cf79b811ac | ||
|
|
2b86defaa7 | ||
|
|
0da40f990f | ||
|
|
d63ae80019 | ||
|
|
cd5c829460 | ||
|
|
14ff11b96b | ||
|
|
12bbd34fe5 | ||
|
|
4a015bfb7c | ||
|
|
aaad7c57c1 | ||
|
|
82a17c59ae | ||
|
|
646722c466 | ||
|
|
02f9ee4629 | ||
|
|
902c147f8e | ||
|
|
92166e6236 | ||
|
|
eb6536a832 | ||
|
|
cf26b92d45 | ||
|
|
687caa60c4 | ||
|
|
9503016e93 | ||
|
|
cce82d001c | ||
|
|
f9d2eddb83 | ||
|
|
d66d48d9a3 | ||
|
|
aab654d4e8 | ||
|
|
b7eeb79a33 | ||
|
|
db7b03ffda | ||
|
|
e27a62210c | ||
|
|
06e9cfe6da | ||
|
|
b6941b78f2 | ||
|
|
d64883cef3 | ||
|
|
7ab3a23f95 | ||
|
|
07050a34bd | ||
|
|
9054341b05 | ||
|
|
30db6aa7fd | ||
|
|
663e7457ab | ||
|
|
2b375bf38c | ||
|
|
172cec105a | ||
|
|
5b28083ef3 | ||
|
|
3072b90817 | ||
|
|
4c29ce57c7 | ||
|
|
291b2c2b03 | ||
|
|
9fa96cab6f | ||
|
|
892c64cc69 | ||
|
|
6fad4fb984 | ||
|
|
32ed49b6d8 | ||
|
|
35992267b9 | ||
|
|
95d025f4c2 | ||
|
|
40b9d160c9 | ||
|
|
984872a0dd | ||
|
|
4d00052e33 | ||
|
|
cc15269fb6 | ||
|
|
1177119180 | ||
|
|
2bfb755693 | ||
|
|
3f287372f4 | ||
|
|
d3e0272348 | ||
|
|
818e0a229e | ||
|
|
09fa6403c5 | ||
|
|
cad3d55f76 | ||
|
|
0d85f3f65f | ||
|
|
39046b18e8 | ||
|
|
46d26691cf | ||
|
|
713fe9176d | ||
|
|
66319c12e6 | ||
|
|
31b0e68219 | ||
|
|
8bf7dd6dfe | ||
|
|
5814b1cb5f | ||
|
|
59cd7697be | ||
|
|
063632d5f5 | ||
|
|
8424af6894 | ||
|
|
a0d2e7d935 | ||
|
|
7e779ceb8b | ||
|
|
5481445402 | ||
|
|
2c62ea5aaa | ||
|
|
0bd5635d74 | ||
|
|
53ef669745 | ||
|
|
44d13ad5d4 | ||
|
|
f66e1a8d91 | ||
|
|
634df8744a | ||
|
|
e8b23d06ed | ||
|
|
c1894da34b | ||
|
|
0482839c1b | ||
|
|
09fdaa392a | ||
|
|
6e5270105b | ||
|
|
7c9db3e706 | ||
|
|
e8b01b7282 | ||
|
|
f22a814607 | ||
|
|
b14027d065 | ||
|
|
5133e20195 | ||
|
|
3e75afe983 | ||
|
|
29df2938b1 | ||
|
|
0808d1f189 | ||
|
|
5a4d8c11e6 | ||
|
|
219d6b6b4e | ||
|
|
251c800717 | ||
|
|
02b3da3eec | ||
|
|
4676650694 | ||
|
|
660288e50b | ||
|
|
53ffc05840 | ||
|
|
5b275cd324 | ||
|
|
05c0a3942f | ||
|
|
fa34419e4b | ||
|
|
a640a5a91a | ||
|
|
7475a27b7b | ||
|
|
721a16e17b | ||
|
|
20c3b24dab | ||
|
|
8d60a65c6c | ||
|
|
72d6a5b383 | ||
|
|
b1104143ef | ||
|
|
1c98b67b44 | ||
|
|
1299432e7a | ||
|
|
271bace8e1 | ||
|
|
dec1f7bd51 | ||
|
|
3943570b53 | ||
|
|
a5b58a242b | ||
|
|
6494dc92e0 | ||
|
|
7cae9ff3c4 | ||
|
|
d9d0c7ad13 | ||
|
|
d60a13d0d3 | ||
|
|
38e2c2522a | ||
|
|
8c46ceea46 | ||
|
|
0f209ac875 | ||
|
|
de7ceb78d7 | ||
|
|
28f0c9802e | ||
|
|
e1a97b5783 | ||
|
|
1ec82f9a9f | ||
|
|
d62d4787ab | ||
|
|
c0cfcda90e | ||
|
|
68d5cd7114 | ||
|
|
37d6df3219 | ||
|
|
59b2aed66c | ||
|
|
cb8761a18c | ||
|
|
f5dbdcadba | ||
|
|
d361eaf394 | ||
|
|
fe4d3a2e94 | ||
|
|
1b5be38573 | ||
|
|
2106e9b21e | ||
|
|
b1cdc91bf4 | ||
|
|
e7cdaa120a | ||
|
|
58a9cbddca | ||
|
|
56d067048f | ||
|
|
b5c00e8040 | ||
|
|
23ae7d42c2 | ||
|
|
1aa3ad9441 | ||
|
|
fc67c07589 | ||
|
|
a4266531f2 | ||
|
|
bb6b34d530 | ||
|
|
be2f80a4cf | ||
|
|
12e1d88d71 | ||
|
|
1a9bf76f69 | ||
|
|
715402eb80 | ||
|
|
18e26a3e4d | ||
|
|
95756af921 | ||
|
|
72e4d61b57 | ||
|
|
6604778b58 | ||
|
|
ce25b504c8 | ||
|
|
5a281cec36 | ||
|
|
33a70e3fa4 | ||
|
|
f5ea4bde3a | ||
|
|
251976843a | ||
|
|
8e17b81335 | ||
|
|
9ac80b72b7 | ||
|
|
a8d3ffaf2e | ||
|
|
f2b07381ab | ||
|
|
bee546c085 | ||
|
|
80392718df | ||
|
|
122a7650ad | ||
|
|
c4c5649ce9 | ||
|
|
da7c9d05b3 | ||
|
|
938c04c32e | ||
|
|
ebcd7beedb | ||
|
|
1e45ee6ed3 | ||
|
|
8bf3aa3982 | ||
|
|
7c723354eb | ||
|
|
c9675c1ba5 | ||
|
|
debbbf6585 | ||
|
|
9e701221fd | ||
|
|
5a76ab5f51 | ||
|
|
ef218f6ced | ||
|
|
8e4d76ef04 | ||
|
|
a8f543b1cc | ||
|
|
ad8dd2f57e | ||
|
|
018ca6a76e | ||
|
|
242fba5b5a | ||
|
|
f19afb61ad | ||
|
|
d9eab08f7f | ||
|
|
c6f61bbcc4 | ||
|
|
70865d77e8 | ||
|
|
d526f569a0 | ||
|
|
957ba89eac | ||
|
|
c4146a25af | ||
|
|
c59deaa19b | ||
|
|
fb6b8c54e2 | ||
|
|
d1a243289c | ||
|
|
eb18b83652 | ||
|
|
c4889805f3 | ||
|
|
b52d83f71e | ||
|
|
72c066552e | ||
|
|
3f254035ec | ||
|
|
545b8f9e8d | ||
|
|
c2e172a8a3 | ||
|
|
9147b0893b | ||
|
|
004628230c | ||
|
|
75cfb4cc28 | ||
|
|
dd60b729ee | ||
|
|
651abefe2b | ||
|
|
8858d0c7e0 | ||
|
|
df589fa039 | ||
|
|
2c8c04518d | ||
|
|
226d535e77 | ||
|
|
2aeaeb2495 | ||
|
|
782331b499 | ||
|
|
4256dec408 | ||
|
|
959c3b6180 | ||
|
|
e00864ba59 | ||
|
|
ee8d4c4a72 | ||
|
|
8e4e46fdd6 | ||
|
|
9351cd381f | ||
|
|
72e3ec1924 | ||
|
|
278dee9871 | ||
|
|
7a8364a0f7 | ||
|
|
c96f20ac6c | ||
|
|
fb6707ec92 | ||
|
|
dfc7fdd786 | ||
|
|
541bc4b519 | ||
|
|
b5c4b2f84b | ||
|
|
75ef2801f5 | ||
|
|
699cf1101f | ||
|
|
07af125581 | ||
|
|
5e5258f410 | ||
|
|
d9672a3587 | ||
|
|
27b18c07f0 | ||
|
|
a06a1853e2 | ||
|
|
67e0a30ea8 | ||
|
|
02d449ced2 | ||
|
|
2e8cc55201 | ||
|
|
c4ec5dfd57 | ||
|
|
fca27e4c63 | ||
|
|
f61130508b | ||
|
|
7ab5bfd238 | ||
|
|
e347c6f99f | ||
|
|
f03a2ffe21 | ||
|
|
d46bea7851 | ||
|
|
a25182804a | ||
|
|
dd178e638f | ||
|
|
4b8f61ed0e | ||
|
|
1f7d2adcbe | ||
|
|
fc83f106f2 | ||
|
|
d1d35697a1 | ||
|
|
6a9154801f | ||
|
|
72c06622be | ||
|
|
ca8cc5a43b | ||
|
|
ac2a95ab1d | ||
|
|
faef0581d1 | ||
|
|
4df6ddc3f3 | ||
|
|
644d598685 | ||
|
|
afd4df1cd9 | ||
|
|
37249efd34 | ||
|
|
98498da9aa | ||
|
|
a2b63b3e3b | ||
|
|
1f3f7cebae | ||
|
|
316bef4484 | ||
|
|
f3fdefa0ec | ||
|
|
7837d91790 | ||
|
|
53b7a6768d | ||
|
|
9d025d7e3c | ||
|
|
945c667281 | ||
|
|
8d7267f7f7 | ||
|
|
0eb2196f99 | ||
|
|
26afbb999c | ||
|
|
d906242b0e | ||
|
|
dbf99fe456 | ||
|
|
7842fca4da | ||
|
|
ef01959fb1 | ||
|
|
ec2cd929ab | ||
|
|
6f79c55c2a | ||
|
|
529cd743a0 | ||
|
|
e632dda2b7 | ||
|
|
d22f313e9e | ||
|
|
e957a4bd85 | ||
|
|
89f90f0b65 | ||
|
|
f965ae85f3 | ||
|
|
983d9a3235 | ||
|
|
87c473666a | ||
|
|
1c9f8a4359 | ||
|
|
d0850614ba | ||
|
|
f4d10e7099 | ||
|
|
1b029a08f6 | ||
|
|
8a5211f260 | ||
|
|
0831968204 | ||
|
|
1e8745416d | ||
|
|
9fc9722501 | ||
|
|
adfd8e43c8 | ||
|
|
f7267efa1b | ||
|
|
d288db6b6e | ||
|
|
ede2ada183 | ||
|
|
507006a08d |
No files matched your search
@@ -568,6 +568,8 @@ impl Chat {
|
||||
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
|
||||
if self.qol != qol {
|
||||
self.qol = qol;
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
}
|
||||
@@ -593,20 +595,25 @@ impl Chat {
|
||||
.filter(|q| !q.trim().is_empty());
|
||||
if self.search != query {
|
||||
self.search = query;
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(font);
|
||||
}
|
||||
}
|
||||
pub fn searching(&self) -> bool {
|
||||
self.search.is_some()
|
||||
}
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
|
||||
/// keeping only the messages the Chat option shows.
|
||||
/// Re-wrap after a width or scale change (`ChatComponent.rescaleChat`):
|
||||
/// `resetChatScroll`, then the messages the Chat option shows.
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
/// `ChatComponent.refreshTrimmedMessages`: the lines are laid out again
|
||||
/// where the reader was (`chatScrollbarPos` survives a filter change);
|
||||
/// `rescale` resets the scroll itself, as `rescaleChat` does.
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.lines.clear();
|
||||
let mut messages = std::mem::take(&mut self.messages);
|
||||
for m in messages.iter_mut().rev() {
|
||||
|
||||
@@ -1067,6 +1067,34 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ChatComponent.refreshTrimmedMessages`: a filter change lays the lines out
|
||||
/// again but keeps `chatScrollbarPos` and `newMessageSinceScroll`; only
|
||||
/// `rescaleChat` resets them, after `resetChatScroll`.
|
||||
#[test]
|
||||
fn a_chat_option_change_keeps_the_readers_place_but_a_rescale_does_not() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
for i in 0..60 {
|
||||
chat.add_message(
|
||||
&font,
|
||||
Text::literal(format!("line {i}")),
|
||||
None,
|
||||
ChatSource::Server,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
chat.scroll = 3;
|
||||
chat.new_since_scroll = true;
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
|
||||
assert_eq!(chat.scroll, 3);
|
||||
assert!(chat.new_since_scroll);
|
||||
let atlas = crate::hud::HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
chat.rescale(&atlas);
|
||||
assert_eq!(chat.scroll, 0);
|
||||
assert!(!chat.new_since_scroll);
|
||||
}
|
||||
|
||||
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
|
||||
/// pointing hand; outside it the cursor stays the arrow.
|
||||
#[test]
|
||||
|
||||
@@ -196,7 +196,7 @@ impl ColumnLayout {
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
pub fn section_index(self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections)
|
||||
}
|
||||
@@ -700,7 +700,7 @@ impl Chunk {
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
#[inline]
|
||||
pub fn section_index(&self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections.len())
|
||||
}
|
||||
@@ -984,6 +984,24 @@ pub struct ChunkExtras {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn extreme_y_queries_are_air_and_writes_leave_the_column_unchanged() {
|
||||
for layout in [ColumnLayout::VANILLA, ColumnLayout::new(0, 256).unwrap()] {
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
chunk.set(0, layout.min_y(), 0, 7);
|
||||
for y in [i32::MIN, i32::MIN + 63, -2_146_769_275, i32::MAX] {
|
||||
assert_eq!(layout.section_index(y), None);
|
||||
assert_eq!(chunk.section_index(y), None);
|
||||
assert_eq!(chunk.get(0, y, 0), 0);
|
||||
assert_eq!(
|
||||
chunk.light(0, y, 0),
|
||||
if y < layout.min_y() { 0 } else { 0xf0 }
|
||||
);
|
||||
chunk.set(0, y, 0, 9);
|
||||
}
|
||||
assert_eq!(chunk.get(0, layout.min_y(), 0), 7);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn each_light_form_keeps_every_value_and_run() {
|
||||
// Sky varies alone, block varies alone, both, neither.
|
||||
let cases: [(fn(usize) -> u8, &str); 4] = [
|
||||
|
||||
@@ -419,8 +419,10 @@ impl SceneCollisions<'_, '_> {
|
||||
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
let cells = [size(lo.x, hi.x), size(lo.y, hi.y), size(lo.z, hi.z)]
|
||||
.into_iter()
|
||||
.try_fold(1i64, |n, size| n.checked_mul(size));
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells.is_none_or(|n| n > 4096) {
|
||||
return false;
|
||||
}
|
||||
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
|
||||
@@ -1111,6 +1113,13 @@ mod section_tests {
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
// The fast path must decline an enormous volume without overflowing
|
||||
// its size calculation or beginning to walk it.
|
||||
let enormous = Aabb::new(
|
||||
Vec3::new(-1.0e9, -1.0e9, -1.0e9),
|
||||
Vec3::new(1.0e9, 1.0e9, 1.0e9),
|
||||
);
|
||||
assert!(!scene.cells_without_collision(enormous));
|
||||
// Under the fence: its section's outer cells may hold a shape reaching in.
|
||||
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
|
||||
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
|
||||
|
||||
@@ -56,6 +56,8 @@ pub struct Overlay {
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
/// By tile: the rules whose `matchTiles` name it (a range of `lists`), which chain on it.
|
||||
chain: Vec<(u32, u32)>,
|
||||
overlay: OverlayJoin,
|
||||
}
|
||||
|
||||
@@ -188,6 +190,7 @@ impl ConnectedTextures {
|
||||
Ok(tiles) => Some(LoadedRule {
|
||||
rule,
|
||||
tiles,
|
||||
chain: Vec::new(),
|
||||
overlay: OverlayJoin::default(),
|
||||
}),
|
||||
Err(e) => {
|
||||
@@ -292,10 +295,23 @@ impl ConnectedTextures {
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
|
||||
// Tile rules by the sprite or tile file each names, for chaining on a picked tile.
|
||||
let mut by_name: HashMap<String, Vec<u16>> = HashMap::new();
|
||||
for (i, r) in out.rules.iter().enumerate() {
|
||||
let i = i as u16;
|
||||
for tile in &r.rule.match_tiles {
|
||||
if let Some(l) = layer(&sprite_name(tile)) {
|
||||
let name = sprite_name(tile);
|
||||
let list = by_name.entry(name.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
// A tile file (`./1.png`) is no model's sprite: only chaining reaches it, by
|
||||
// name. By layer it would match every face whose sprite has the same pixels
|
||||
// (identical stills share a layer: Conquest's blank placeholders, dozens).
|
||||
if !name.contains(':') {
|
||||
continue;
|
||||
}
|
||||
if let Some(l) = layer(&name) {
|
||||
let l = l as usize;
|
||||
if by_layer.len() <= l {
|
||||
by_layer.resize(l + 1, Vec::new());
|
||||
@@ -316,6 +332,26 @@ impl ConnectedTextures {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_layer.push(range);
|
||||
}
|
||||
let by_name: HashMap<String, (u32, u32)> = by_name
|
||||
.into_iter()
|
||||
.map(|(name, list)| (name, push_list(&mut out.lists, &list)))
|
||||
.collect();
|
||||
// Set at the end: `by_block` borrows the rules' names.
|
||||
let chains: Vec<Vec<(u32, u32)>> = out
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.rule
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|t| match t {
|
||||
Tile::Texture(path) => by_name.get(&sprite_name(path)).copied(),
|
||||
_ => None,
|
||||
})
|
||||
.map(Option::unwrap_or_default)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let mut list = Vec::new();
|
||||
for state in states {
|
||||
let Some((name, props)) = state else {
|
||||
@@ -355,6 +391,9 @@ impl ConnectedTextures {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_state.push(range);
|
||||
}
|
||||
for (r, chain) in out.rules.iter_mut().zip(chains) {
|
||||
r.chain = chain;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -392,9 +431,11 @@ impl ConnectedTextures {
|
||||
}
|
||||
|
||||
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
|
||||
ranges.get(i).map_or(&[], |&(start, len)| {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
})
|
||||
ranges.get(i).map_or(&[], |&range| self.range(range))
|
||||
}
|
||||
|
||||
fn range(&self, (start, len): (u32, u32)) -> &[u16] {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
@@ -441,7 +482,7 @@ impl ConnectedTextures {
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) = self.apply(
|
||||
&& let Some((pick, index)) = self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
@@ -454,7 +495,7 @@ impl ConnectedTextures {
|
||||
)
|
||||
{
|
||||
// "Tiles output by CTM rules can also be matched by another rule": once, by the
|
||||
// tile rules naming the picked tile (and this block, if they name blocks).
|
||||
// tile rules naming the picked tile by name (and this block, if they name blocks).
|
||||
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
|
||||
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
|
||||
return pick;
|
||||
@@ -467,7 +508,7 @@ impl ConnectedTextures {
|
||||
// again: an overlay would draw twice.
|
||||
let first = self.list(&self.by_layer, layer as usize);
|
||||
let again = self
|
||||
.list(&self.by_layer, picked as usize)
|
||||
.range(r.chain.get(index).copied().unwrap_or_default())
|
||||
.iter()
|
||||
.map(|&j| (j, &self.rules[usize::from(j)]))
|
||||
.filter(|&(j, r)| {
|
||||
@@ -488,8 +529,8 @@ impl ConnectedTextures {
|
||||
&texture,
|
||||
&mut overlay,
|
||||
) {
|
||||
Some(Pick::Keep) => return pick,
|
||||
Some(chained) => return chained,
|
||||
Some((Pick::Keep, _)) => return pick,
|
||||
Some((chained, _)) => return chained,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -512,7 +553,7 @@ impl ConnectedTextures {
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
texture: &impl Fn() -> Option<Axes>,
|
||||
overlay: &mut impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
) -> Option<(Pick, usize)> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
// a custom name are not loaded.
|
||||
@@ -602,7 +643,7 @@ impl ConnectedTextures {
|
||||
return if quarters.contains(&TileLayer::Skip) {
|
||||
None
|
||||
} else {
|
||||
Some(Pick::Quarters(quarters))
|
||||
Some((Pick::Quarters(quarters), 0))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -617,7 +658,7 @@ impl ConnectedTextures {
|
||||
return None;
|
||||
}
|
||||
if !joins([0, 1, 0]) {
|
||||
return Some(Pick::Keep);
|
||||
return Some((Pick::Keep, 0));
|
||||
}
|
||||
0
|
||||
}
|
||||
@@ -644,8 +685,8 @@ impl ConnectedTextures {
|
||||
}
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
t => Some(Pick::Tile(t)),
|
||||
TileLayer::Default => Some((Pick::Keep, index)),
|
||||
t => Some((Pick::Tile(t), index)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,6 +716,7 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
overlay: OverlayJoin::default(),
|
||||
chain: Vec::new(),
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
@@ -722,6 +764,10 @@ mod tests {
|
||||
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
|
||||
// And `a0` of the first rule.
|
||||
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
|
||||
// Two blank placeholders with the same pixels, so one layer: the tile `grass/1`
|
||||
// of the third rule (`chaining_follows_the_tile_not_its_pixels`).
|
||||
"assets/minecraft/optifine/ctm/grass/1.png"
|
||||
| "assets/minecraft/optifine/ctm/aaa/1.png" => Some(3000),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
@@ -1176,6 +1222,31 @@ mod tests {
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chaining_follows_the_tile_not_its_pixels() {
|
||||
// Conquest's grass top: a placeholder tile that a rule beside it replaces. Another
|
||||
// folder's placeholder has the same pixels, hence the same layer, and its rule sorts
|
||||
// first; it must not draw on the grass.
|
||||
let ctm = world(&[
|
||||
(
|
||||
"grass/top.properties",
|
||||
"matchTiles=glass\nmethod=fixed\ntiles=1",
|
||||
),
|
||||
(
|
||||
"grass/random_1.properties",
|
||||
"matchTiles=./1.png\nmethod=fixed\ntiles=17\nfaces=top",
|
||||
),
|
||||
(
|
||||
"aaa/random_1.properties",
|
||||
"matchTiles=./1.png\nmethod=fixed\ntiles=5\nfaces=top",
|
||||
),
|
||||
]);
|
||||
// Rules in path order: aaa/random_1 (1000), grass/random_1 (2000), grass/top (3000).
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// A face whose own sprite has a placeholder's pixels is no placeholder.
|
||||
assert_eq!(pick_in(&ctm, 2, 3000, Face::Up, &[]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orient_texture_follows_the_quads_uvs() {
|
||||
let rule = |orient: &str| {
|
||||
|
||||
@@ -56,6 +56,15 @@ pub struct Glue<'a> {
|
||||
pub struct CoreProgram {
|
||||
pub vertex: naga::Module,
|
||||
pub fragment: naga::Module,
|
||||
/// The blocks and samplers that are none of vanilla's, in binding order: the `i`th is at
|
||||
/// [`other_binding`]`(i)` (a sampler's object one after its texture; a block leaves that
|
||||
/// binding unused).
|
||||
pub other: Vec<String>,
|
||||
}
|
||||
|
||||
/// The binding of the `index`th block or sampler of [`CoreProgram::other`].
|
||||
pub fn other_binding(index: usize) -> u32 {
|
||||
OTHER_BINDINGS + 2 * index as u32
|
||||
}
|
||||
|
||||
/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it.
|
||||
@@ -66,6 +75,20 @@ pub fn translate_core(
|
||||
defines: &[&str],
|
||||
include: &dyn Fn(&str) -> Option<String>,
|
||||
glue: &Glue,
|
||||
) -> Result<CoreProgram, String> {
|
||||
translate_core_with(vertex, fragment, defines, include, glue, &|_| false)
|
||||
}
|
||||
|
||||
/// [`translate_core`] for a pipeline that also gives the blocks and samplers `provided` says
|
||||
/// yes to, at their [`CoreProgram::other`] bindings; the others are still an error. `provided`
|
||||
/// answers a block's name and a sampler's alike: the caller knows which kind each one is.
|
||||
pub fn translate_core_with(
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
defines: &[&str],
|
||||
include: &dyn Fn(&str) -> Option<String>,
|
||||
glue: &Glue,
|
||||
provided: &dyn Fn(&str) -> bool,
|
||||
) -> Result<CoreProgram, String> {
|
||||
const CORE: &str = "minecraft:shaders/core/";
|
||||
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
|
||||
@@ -75,10 +98,16 @@ pub fn translate_core(
|
||||
.map_err(|e| format!("vertex shader: {e}"))?;
|
||||
let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines)
|
||||
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines, &names, provided)
|
||||
.map_err(|e| format!("vertex shader: {e}"))?;
|
||||
let fragment = compile(ShaderStage::Fragment, &fragment_code, defines)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let fragment = compile(
|
||||
ShaderStage::Fragment,
|
||||
&fragment_code,
|
||||
defines,
|
||||
&names,
|
||||
provided,
|
||||
)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let given = locations(&vertex, false);
|
||||
if let Some(missing) = locations(&fragment, true)
|
||||
.into_iter()
|
||||
@@ -88,7 +117,11 @@ pub fn translate_core(
|
||||
"the fragment shader reads location {missing}, which the vertex shader doesn't write"
|
||||
));
|
||||
}
|
||||
Ok(CoreProgram { vertex, fragment })
|
||||
Ok(CoreProgram {
|
||||
vertex,
|
||||
fragment,
|
||||
other: names.other,
|
||||
})
|
||||
}
|
||||
|
||||
/// A bound on a pack's includes, which are pasted every time.
|
||||
@@ -309,7 +342,9 @@ fn block_name(line: &str) -> Option<&str> {
|
||||
let rest = line[at + "uniform".len()..].trim();
|
||||
let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
|
||||
let (name, tail) = rest.split_at(name_end);
|
||||
(!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name)
|
||||
// `{` alone, or the whole block on the line (`uniform Day { float daylight; };`).
|
||||
(!name.is_empty() && (tail.trim().is_empty() || tail.trim_start().starts_with('{')))
|
||||
.then_some(name)
|
||||
}
|
||||
|
||||
/// What the two stages share: the vertex stage's outputs and their locations, and the names of
|
||||
@@ -337,7 +372,7 @@ impl Names {
|
||||
self.other.len() - 1
|
||||
}
|
||||
};
|
||||
OTHER_BINDINGS + 2 * index as u32
|
||||
other_binding(index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +586,13 @@ fn rename_main(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// Parses and validates one stage; errors name the line of the rewritten code.
|
||||
fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Module, String> {
|
||||
fn compile(
|
||||
stage: ShaderStage,
|
||||
code: &str,
|
||||
defines: &[&str],
|
||||
names: &Names,
|
||||
provided: &dyn Fn(&str) -> bool,
|
||||
) -> Result<naga::Module, String> {
|
||||
let mut options = Options::from(stage);
|
||||
for define in defines {
|
||||
let (name, value) = define.split_once(' ').unwrap_or((define, ""));
|
||||
@@ -583,16 +624,16 @@ fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Mod
|
||||
})?;
|
||||
let entry = info.get_entry_point(0);
|
||||
for (handle, global) in module.global_variables.iter() {
|
||||
let other = global
|
||||
.binding
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.binding >= OTHER_BINDINGS);
|
||||
if other && !entry[handle].is_empty() {
|
||||
let name = global.name.as_deref().unwrap_or("?");
|
||||
let name = name
|
||||
.strip_prefix("hz_tex_")
|
||||
.or_else(|| name.strip_prefix("hz_smp_"))
|
||||
.unwrap_or(name);
|
||||
let Some(binding) = global.binding.as_ref().map(|b| b.binding) else {
|
||||
continue;
|
||||
};
|
||||
if binding >= OTHER_BINDINGS && !entry[handle].is_empty() {
|
||||
// The name it was declared with (a block's instance may have none).
|
||||
let index = ((binding - OTHER_BINDINGS) / 2) as usize;
|
||||
let name = names.other.get(index).map_or("?", String::as_str);
|
||||
if provided(name) {
|
||||
continue;
|
||||
}
|
||||
return Err(format!("{name} is not a block or sampler of this pipeline"));
|
||||
}
|
||||
}
|
||||
@@ -868,6 +909,63 @@ mod tests {
|
||||
translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
|
||||
}
|
||||
|
||||
/// A block declared on one line, as packs write a one-float uniform, is bound like one
|
||||
/// over several lines; the blocks and samplers the caller provides are accepted, in binding
|
||||
/// order, and one it doesn't provide is not.
|
||||
#[test]
|
||||
fn a_block_on_one_line_is_bound_and_provided_blocks_are_accepted() {
|
||||
let vsh = "#version 330\nlayout(std140) uniform Day { float daylight; };\nout vec4 vertexColor;\n\
|
||||
void main() { vertexColor = vec4(daylight); gl_Position = vec4(0.0); }\n";
|
||||
let fsh = "#version 330\nuniform sampler2D Bounds;\nin vec4 vertexColor;\nout vec4 fragColor;\n\
|
||||
void main() { fragColor = vertexColor * texture(Bounds, vec2(0.5)); }\n";
|
||||
let both = |name: &str| name == "Day" || name == "Bounds";
|
||||
let program = translate_core_with(vsh, fsh, &[], &include, &GLUE, &both).unwrap();
|
||||
assert_eq!(program.other, ["Day", "Bounds"]);
|
||||
// The bindings the caller gives them at.
|
||||
let bindings = |module: &naga::Module| -> Vec<(Option<String>, u32)> {
|
||||
module
|
||||
.global_variables
|
||||
.iter()
|
||||
.filter_map(|(_, g)| Some((g.name.clone(), g.binding.as_ref()?.binding)))
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
bindings(&program.vertex)
|
||||
.iter()
|
||||
.any(|(_, b)| *b == other_binding(0)),
|
||||
"{:?}",
|
||||
bindings(&program.vertex)
|
||||
);
|
||||
let fragment = bindings(&program.fragment);
|
||||
assert!(
|
||||
fragment.contains(&(Some("hz_tex_Bounds".into()), other_binding(1))),
|
||||
"{fragment:?}"
|
||||
);
|
||||
assert!(
|
||||
fragment.contains(&(Some("hz_smp_Bounds".into()), other_binding(1) + 1)),
|
||||
"{fragment:?}"
|
||||
);
|
||||
let error = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap_err();
|
||||
assert!(error.contains("Day is not a block or sampler"), "{error}");
|
||||
let only_day = |name: &str| name == "Day";
|
||||
let error = translate_core_with(vsh, fsh, &[], &include, &GLUE, &only_day).unwrap_err();
|
||||
assert!(
|
||||
error.contains("Bounds is not a block or sampler"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only a uniform block's declaration names a block, written over several lines or on one.
|
||||
#[test]
|
||||
fn block_names_are_read_from_block_declarations_only() {
|
||||
assert_eq!(block_name("layout(std140) uniform Day {"), Some("Day"));
|
||||
assert_eq!(block_name("uniform Day { float daylight; };"), Some("Day"));
|
||||
assert_eq!(block_name("uniform Foo{"), Some("Foo"));
|
||||
assert_eq!(block_name("uniform Globals"), Some("Globals"));
|
||||
assert_eq!(block_name("uniform float x;"), None);
|
||||
assert_eq!(block_name("uniform sampler2D Sampler0;"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_horizon_does_not_provide_is_an_error() {
|
||||
let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n";
|
||||
|
||||
@@ -396,6 +396,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
|
||||
let program = crate::core_shader::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
|
||||
other: Vec::new(),
|
||||
};
|
||||
gpu.hud.set_text_shader(&gpu.device, Some(&program));
|
||||
assert!(gpu.hud.pack_text.is_some());
|
||||
@@ -417,6 +418,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
|
||||
let program = crate::core_shader::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
|
||||
other: Vec::new(),
|
||||
};
|
||||
gpu.hud.set_text_shader(&gpu.device, Some(&program));
|
||||
assert!(gpu.hud.pack_text.is_none());
|
||||
|
||||
@@ -2259,22 +2259,14 @@ impl Mesher<'_> {
|
||||
translucent: face.translucent,
|
||||
opaque: face.opaque,
|
||||
};
|
||||
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
|
||||
let tile = |t: TileLayer| match t {
|
||||
TileLayer::Layer {
|
||||
layer,
|
||||
translucent,
|
||||
opaque,
|
||||
} => TileLayer::Layer {
|
||||
layer,
|
||||
translucent: translucent || q.render_type.is_none() && q.material.force_translucent,
|
||||
opaque,
|
||||
},
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
};
|
||||
let own_translucent = self.translucent(own);
|
||||
let tile = |t: TileLayer| in_face_pass(t, own, own_translucent);
|
||||
match pick {
|
||||
Pick::Keep => self.quad(out, force_solid, own, corners.map(|c| c.0), tint),
|
||||
Pick::Tile(t) => self.quad(out, force_solid, tile(t), corners.map(|c| c.0), tint),
|
||||
Pick::Tile(t) => {
|
||||
let v = corners.map(|c| c.0);
|
||||
self.quad_as(out, force_solid, tile(t), v, false, tint);
|
||||
}
|
||||
Pick::Quarters(tiles) => {
|
||||
let cuts = cuts(&corners);
|
||||
for s in cuts.windows(2) {
|
||||
@@ -2283,7 +2275,7 @@ impl Mesher<'_> {
|
||||
.map(|(s, t)| bilinear(&corners, s, t));
|
||||
let quarter = quarter_of(&piece);
|
||||
let piece = piece.map(|c| c.0);
|
||||
self.quad(out, force_solid, tile(tiles[quarter]), piece, tint);
|
||||
self.quad_as(out, force_solid, tile(tiles[quarter]), piece, false, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2296,12 +2288,7 @@ impl Mesher<'_> {
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let face_translucent = match pick {
|
||||
Pick::Keep => self.translucent(own),
|
||||
Pick::Tile(t) => self.translucent(tile(t)),
|
||||
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
|
||||
Pick::Nothing => false,
|
||||
};
|
||||
let face_translucent = !matches!(pick, Pick::Nothing) && own_translucent;
|
||||
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
|
||||
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
|
||||
// rotations).
|
||||
@@ -2450,6 +2437,21 @@ fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Tile `t` as drawn over a face whose own sprite is `own`, in the face's pass
|
||||
/// (`own_translucent`): CTM picks a texture, not a render layer (OptiFine draws a tile in the
|
||||
/// block's). The tile's texels only drop the alpha test when all are opaque. A Conquest grass
|
||||
/// blade tile with 7 partly clear texels of 1024 blended, and wrote the depth of its clear
|
||||
/// rectangle, which Bliss fogs. `<skip>` and `<default>` are the face's own sprite.
|
||||
fn in_face_pass(t: TileLayer, own: TileLayer, own_translucent: bool) -> TileLayer {
|
||||
match t {
|
||||
TileLayer::Layer { layer, opaque, .. } => TileLayer::Layer {
|
||||
layer,
|
||||
translucent: own_translucent,
|
||||
opaque,
|
||||
},
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
}
|
||||
}
|
||||
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
|
||||
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
|
||||
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
|
||||
@@ -2574,6 +2576,38 @@ mod quarter_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_draws_in_its_faces_pass() {
|
||||
// Conquest's grass blade tile: a few partly clear texels, over an opaque face.
|
||||
let blade = TileLayer::Layer {
|
||||
layer: 5,
|
||||
translucent: true,
|
||||
opaque: false,
|
||||
};
|
||||
let face = |translucent| TileLayer::Layer {
|
||||
layer: 1,
|
||||
translucent,
|
||||
opaque: !translucent,
|
||||
};
|
||||
let cut = TileLayer::Layer {
|
||||
layer: 5,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(in_face_pass(blade, face(false), false), cut);
|
||||
// Over stained glass the tile blends, as the face does.
|
||||
let blend = TileLayer::Layer {
|
||||
layer: 5,
|
||||
translucent: true,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(in_face_pass(blade, face(true), true), blend);
|
||||
assert_eq!(
|
||||
in_face_pass(TileLayer::Default, face(false), false),
|
||||
face(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_take_the_cubes_own_uvs() {
|
||||
use horizon_assets::model::Direction as D;
|
||||
|
||||
@@ -4693,20 +4693,19 @@ impl ParticleEngine {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
|
||||
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`), turned by
|
||||
/// `Camera.rotation()`: `rotationYXZ(PI - yRot, -xRot, 0)`.
|
||||
pub fn render(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.render_inner::<false>(
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
base,
|
||||
partial,
|
||||
0.0,
|
||||
None,
|
||||
@@ -4721,8 +4720,7 @@ impl ParticleEngine {
|
||||
pub fn render_culled(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
pixel_focal_length: f32,
|
||||
culling_enabled: bool,
|
||||
@@ -4732,13 +4730,12 @@ impl ParticleEngine {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !culling_enabled {
|
||||
self.render(camera, yaw, pitch, partial, light_at, out);
|
||||
self.render(camera, base, partial, light_at, out);
|
||||
return;
|
||||
}
|
||||
self.render_inner::<true>(
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
base,
|
||||
partial,
|
||||
pixel_focal_length,
|
||||
frustum,
|
||||
@@ -4751,8 +4748,7 @@ impl ParticleEngine {
|
||||
fn render_inner<const CULL: bool>(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
pixel_focal_length: f32,
|
||||
frustum: Option<&crate::gfx::Frustum>,
|
||||
@@ -4760,9 +4756,6 @@ impl ParticleEngine {
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
|
||||
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
|
||||
* Quat::from_rotation_x(-pitch.to_radians());
|
||||
let mut light_cache = ParticleLightCache::default();
|
||||
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
|
||||
additive.clear();
|
||||
@@ -5173,12 +5166,11 @@ mod tests {
|
||||
|
||||
let light_at = |_: [i32; 3]| 0xf0;
|
||||
let mut regular = Vec::new();
|
||||
engine.render(DVec3::ZERO, 0.0, 0.0, 0.5, &light_at, &mut regular);
|
||||
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &light_at, &mut regular);
|
||||
let mut disabled = Vec::new();
|
||||
engine.render_culled(
|
||||
DVec3::ZERO,
|
||||
0.0,
|
||||
0.0,
|
||||
Quat::IDENTITY,
|
||||
0.5,
|
||||
900.0,
|
||||
false,
|
||||
@@ -5207,6 +5199,36 @@ mod tests {
|
||||
assert_eq!(attributes(&disabled), attributes(®ular));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quads_face_the_rotation_they_are_given() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let sprite = "test:smoke".to_owned();
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
engine.atlas.insert(sprite.clone(), (rect, false));
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let mut particle =
|
||||
Particle::moving(&mut random, ParticleType::Smoke, [0.0, 0.0, -3.0], [0.0; 3]);
|
||||
particle.sprite = Sprite::Named(sprite);
|
||||
engine.particles.push(particle);
|
||||
let draw = |rotation: Quat| {
|
||||
let mut out = Vec::new();
|
||||
engine.render(DVec3::ZERO, rotation, 0.5, &|_| 0xf0, &mut out);
|
||||
out.iter().map(|v| v.pos).collect::<Vec<_>>()
|
||||
};
|
||||
// A bed's view turns the quads as the player's own yaw would not.
|
||||
let ahead = draw(Quat::IDENTITY);
|
||||
let turned = draw(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
|
||||
assert_eq!(ahead.len(), 4);
|
||||
assert_ne!(ahead, turned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_particles_json_becomes_its_sprite_set() {
|
||||
let dir =
|
||||
@@ -5600,7 +5622,7 @@ mod tests {
|
||||
particle.sprite = Sprite::Index(0);
|
||||
engine.particles.push(particle);
|
||||
let mut vertices = Vec::new();
|
||||
engine.render(DVec3::ZERO, 0.0, 0.0, 0.0, &|_| 0, &mut vertices);
|
||||
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.0, &|_| 0, &mut vertices);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
assert!(vertices.iter().all(
|
||||
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
|
||||
|
||||
@@ -143,21 +143,39 @@ mod tests {
|
||||
p.size = 0.1;
|
||||
e.particles.push(p.clone());
|
||||
let mut vertices = Vec::new();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
e.render(
|
||||
DVec3::ZERO,
|
||||
glam::Quat::IDENTITY,
|
||||
0.5,
|
||||
&|_| 0,
|
||||
&mut vertices,
|
||||
);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
assert!(vertices.iter().all(
|
||||
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
|
||||
));
|
||||
e.particles[0].set_visible(false);
|
||||
vertices.clear();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
e.render(
|
||||
DVec3::ZERO,
|
||||
glam::Quat::IDENTITY,
|
||||
0.5,
|
||||
&|_| 0,
|
||||
&mut vertices,
|
||||
);
|
||||
assert!(vertices.is_empty());
|
||||
assert_eq!(e.particles[0].age, 2);
|
||||
e.pending.push(copy);
|
||||
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
assert!(e.pending.is_empty());
|
||||
vertices.clear();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
e.render(
|
||||
DVec3::ZERO,
|
||||
glam::Quat::IDENTITY,
|
||||
0.5,
|
||||
&|_| 0,
|
||||
&mut vertices,
|
||||
);
|
||||
assert!(vertices.is_empty());
|
||||
e.tick_particle(&mut p, &level);
|
||||
assert!(p.removed);
|
||||
|
||||
@@ -1971,7 +1971,7 @@ mod tests {
|
||||
// The camera 5 blocks south, at the particle's height.
|
||||
let camera = DVec3::new(p.position[0], p.position[1], p.position[2] + 5.0);
|
||||
let mut out = Vec::new();
|
||||
engine.render(camera, 0.0, 0.0, 0.0, &|_| 0xF0, &mut out);
|
||||
engine.render(camera, glam::Quat::IDENTITY, 0.0, &|_| 0xF0, &mut out);
|
||||
assert_eq!(out.len(), 4);
|
||||
for v in &out {
|
||||
// The texture's top row (v 0) under the centre, its bottom row over it.
|
||||
@@ -2023,8 +2023,7 @@ mod tests {
|
||||
let mut out = Vec::new();
|
||||
engine.render(
|
||||
DVec3::new(1.0, 64.0, 6.0),
|
||||
0.0,
|
||||
0.0,
|
||||
glam::Quat::IDENTITY,
|
||||
0.0,
|
||||
&|_| 0xF0,
|
||||
&mut out,
|
||||
@@ -2138,8 +2137,7 @@ mod tests {
|
||||
let mut out = Vec::new();
|
||||
engine.render(
|
||||
DVec3::new(1.0, 70.0, 1.0),
|
||||
0.0,
|
||||
0.0,
|
||||
glam::Quat::IDENTITY,
|
||||
0.0,
|
||||
&|_| 0xF0,
|
||||
&mut out,
|
||||
|
||||
@@ -580,6 +580,15 @@ pub fn button(
|
||||
"widget/button"
|
||||
};
|
||||
sprite(out, sprite_id, x, y, w, h);
|
||||
// `AbstractWidget.WithInactiveMessage.defaultInactiveMessage`: an inactive
|
||||
// widget's message is -6250336 (0xFFA0A0A0), not the active white.
|
||||
let inactive;
|
||||
let label = if active {
|
||||
label
|
||||
} else {
|
||||
inactive = label.clone().with_color(0xFFA0_A0A0);
|
||||
&inactive
|
||||
};
|
||||
scrolling_string_over_contents(out, m, label, x, y, w, h, 2, now_ms);
|
||||
}
|
||||
/// `Tooltip.splitTooltip` of a widget's tooltip, shown under the mouse.
|
||||
@@ -1150,6 +1159,33 @@ mod button_label_tests {
|
||||
button(&mut out, m, text, 10, 20, w, 20, true, false, now_ms);
|
||||
out
|
||||
}
|
||||
/// `WithInactiveMessage`: a disabled button's message is grey, not white.
|
||||
#[test]
|
||||
fn an_inactive_buttons_label_is_grey() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut out = Vec::new();
|
||||
button(
|
||||
&mut out,
|
||||
&m,
|
||||
&Text::literal("Go"),
|
||||
10,
|
||||
20,
|
||||
100,
|
||||
20,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
let color = out
|
||||
.iter()
|
||||
.find_map(|d| match d {
|
||||
Draw::Text { text, .. } => text.spans.iter().find_map(|s| s.style.color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the label is drawn with its inactive style");
|
||||
assert_eq!(color & 0xFF_FFFF, 0xA0_A0A0);
|
||||
}
|
||||
fn scissor_of(draws: &[Draw]) -> Option<[i32; 4]> {
|
||||
draws.iter().find_map(|d| match d {
|
||||
Draw::Scissor { x0, y0, x1, y1 } => Some([*x0, *y0, *x1, *y1]),
|
||||
|
||||
@@ -506,8 +506,8 @@ pub struct Streaming {
|
||||
decoration_order: DecorationOrder,
|
||||
order_area: Option<((i32, i32), i32)>,
|
||||
/// What the queued jobs' priorities were computed from (`requeue`): the
|
||||
/// centre, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
|
||||
/// centre, the band, the extra areas, and the forced chunks.
|
||||
queue_basis: forced_ring::Basis,
|
||||
finishing: FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
/// `ChunkGenerator::decorates_beside_final`.
|
||||
@@ -1071,6 +1071,7 @@ pub struct WorkerContext {
|
||||
}
|
||||
mod bounded;
|
||||
mod forced_cover;
|
||||
mod forced_ring;
|
||||
mod relight_now;
|
||||
use bounded::nearest_first;
|
||||
use forced_cover::ForcedCover;
|
||||
@@ -1354,11 +1355,9 @@ impl Streaming {
|
||||
/// render distance 32 nothing was decorated until every proto chunk had
|
||||
/// been generated.
|
||||
fn requeue(&mut self) {
|
||||
let basis = (self.center, self.band);
|
||||
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
|
||||
if !forced_ring::basis_changed(self) {
|
||||
return;
|
||||
}
|
||||
self.queue_basis = (basis, self.extra.clone());
|
||||
let mut queue = self.shared.queue.lock().unwrap();
|
||||
rank_again(&mut queue, |x, z| self.distance(x, z));
|
||||
}
|
||||
@@ -2862,7 +2861,7 @@ impl Ranges<'_> {
|
||||
_ => own,
|
||||
}
|
||||
}
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
fn player_distance(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
dx * dx + dz * dz
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! How a forced chunk's ring is ranked (#13175).
|
||||
use super::{Band, FORCED_TERRAIN_RING, Ranges, Streaming};
|
||||
use std::collections::HashSet;
|
||||
|
||||
type Chunks = HashSet<(i32, i32)>;
|
||||
|
||||
/// What queued jobs were ranked from (`Streaming::requeue`): the centre, the
|
||||
/// band, the extra areas, and the forced chunks.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Basis {
|
||||
player: ((i32, i32), Option<Band>),
|
||||
extra: Vec<((i32, i32), i32)>,
|
||||
forced: (Chunks, Chunks),
|
||||
}
|
||||
|
||||
/// Whether the queued jobs' ranking is out of date; it is brought up to date.
|
||||
pub(super) fn basis_changed(s: &mut Streaming) -> bool {
|
||||
let b = &mut s.queue_basis;
|
||||
let same = b.player == (s.center, s.band)
|
||||
&& b.extra == s.extra
|
||||
&& b.forced.0 == s.forced
|
||||
&& b.forced.1 == s.mod_forced;
|
||||
if !same {
|
||||
b.player = (s.center, s.band);
|
||||
b.extra.clone_from(&s.extra);
|
||||
b.forced.0.clone_from(&s.forced);
|
||||
b.forced.1.clone_from(&s.mod_forced);
|
||||
}
|
||||
!same
|
||||
}
|
||||
|
||||
impl Ranges<'_> {
|
||||
/// `player_distance`, or the nearer one to a forced chunk whose ring reaches
|
||||
/// (x, z): see `forced_distance`.
|
||||
pub(super) fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
self.player_distance(x, z).min(self.forced_distance(x, z))
|
||||
}
|
||||
|
||||
/// Distance (squared) to the nearest forced chunk whose ring reaches
|
||||
/// (x, z), or `i64::MAX`. A forced chunk's ring is loaded with its ticket
|
||||
/// (levels 32 to 35 around the FORCED ticket's 31), so the 3x3 it needs to
|
||||
/// tick is ranked around it, not by how far it lies from the player: vanilla
|
||||
/// ticks the chunk on the first tick (`ChunkMap.prepareTickingChunk`).
|
||||
pub(super) fn forced_distance(&self, x: i32, z: i32) -> i64 {
|
||||
const SIDE: usize = (2 * FORCED_TERRAIN_RING + 1) as usize;
|
||||
let held = self.forced.len() + self.mod_forced.len();
|
||||
if held == 0 {
|
||||
return i64::MAX;
|
||||
}
|
||||
let d = |f: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - f.0), i64::from(z - f.1));
|
||||
dx * dx + dz * dz
|
||||
};
|
||||
if held <= SIDE * SIDE {
|
||||
return self
|
||||
.forced_chunks()
|
||||
.filter(|f| (x - f.0).abs().max((z - f.1).abs()) <= FORCED_TERRAIN_RING)
|
||||
.map(|&f| d(f))
|
||||
.min()
|
||||
.unwrap_or(i64::MAX);
|
||||
}
|
||||
// Many forced chunks: look at the columns around (x, z) instead.
|
||||
(-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING)
|
||||
.flat_map(|dz| (-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING).map(move |dx| (dx, dz)))
|
||||
.map(|(dx, dz)| (x + dx, z + dz))
|
||||
.filter(|f| self.is_forced(f.0, f.1))
|
||||
.map(d)
|
||||
.min()
|
||||
.unwrap_or(i64::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{GenerationInputs, GenerationScan, NO_CHUNKS};
|
||||
use super::*;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::world::World;
|
||||
|
||||
/// A forced chunk's ring is loaded with its ticket, so it ranks around
|
||||
/// the forced chunk and not by how far it lies from the player. Vanilla
|
||||
/// then ticks the chunk on the first tick after a reload
|
||||
/// (`ChunkMap.prepareTickingChunk`: its 3x3 is full at once); ranked from
|
||||
/// the player at the origin, a ring 4096 blocks out arrived west/north
|
||||
/// first, over tens of ticks.
|
||||
#[test]
|
||||
fn a_forced_chunks_ring_is_ranked_around_it() {
|
||||
let ranges = |forced| Ranges {
|
||||
center: (0, 0),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let forced: HashSet<(i32, i32)> = [(256, 256)].into();
|
||||
let r = ranges(&forced);
|
||||
// Squared distance to the forced chunk, by hand: (255, 254) is
|
||||
// one west and two north of it.
|
||||
assert_eq!(r.priority(256, 256), 0);
|
||||
assert_eq!(r.priority(255, 254), 1 + 4);
|
||||
assert_eq!(r.priority(260, 252), 16 + 16);
|
||||
// Past the fourth ring (level 36 and up) the player's distance rules,
|
||||
// as does the whole map without a forced chunk.
|
||||
assert_eq!(r.priority(261, 256), 261 * 261 + 256 * 256);
|
||||
let none = HashSet::new();
|
||||
assert_eq!(ranges(&none).priority(255, 254), 255 * 255 + 254 * 254);
|
||||
// Many forced chunks (the neighbour lookups) agree with few.
|
||||
let many: HashSet<(i32, i32)> = (0..100).map(|i| (256 + 20 * i, 256)).collect();
|
||||
assert_eq!(ranges(&many).priority(255, 254), 5);
|
||||
assert_eq!(ranges(&many).priority(276, 258), 4);
|
||||
// The generation plan fetches the 3x3 the chunk ticks with first.
|
||||
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let (generating, finishing) = (FxHashSet::default(), FxHashSet::default());
|
||||
let protos = FxHashMap::default();
|
||||
let input = GenerationInputs {
|
||||
ranges: ranges(&forced),
|
||||
world: &world,
|
||||
generating: &generating,
|
||||
protos: &protos,
|
||||
finishing: &finishing,
|
||||
decorates: true,
|
||||
remote: false,
|
||||
};
|
||||
let candidates = GenerationScan::default()
|
||||
.candidates(input, usize::MAX)
|
||||
.expect("a scan");
|
||||
let ring: Vec<(i32, i32)> = candidates
|
||||
.iter()
|
||||
.map(|&(_, x, z)| (x, z))
|
||||
.filter(|&(x, z)| (x - 256).abs().max((z - 256).abs()) <= FORCED_TERRAIN_RING)
|
||||
.collect();
|
||||
assert_eq!(ring.len(), 81);
|
||||
assert_eq!(ring[0], (256, 256));
|
||||
let d = |(x, z): (i32, i32)| (x - 256).pow(2) + (z - 256).pow(2);
|
||||
for pair in ring.windows(2) {
|
||||
assert!(
|
||||
d(pair[0]) <= d(pair[1]),
|
||||
"{:?} before {:?}",
|
||||
pair[0],
|
||||
pair[1]
|
||||
);
|
||||
}
|
||||
let first_nine: HashSet<(i32, i32)> = ring[..9].iter().copied().collect();
|
||||
let expected: HashSet<(i32, i32)> = (-1..=1)
|
||||
.flat_map(|dx| (-1..=1).map(move |dz| (256 + dx, 256 + dz)))
|
||||
.collect();
|
||||
assert_eq!(first_nine, expected);
|
||||
}
|
||||
|
||||
/// Jobs queued before a chunk was forced follow it once it is
|
||||
/// (`Streaming::requeue` ranks them again).
|
||||
#[test]
|
||||
fn queued_jobs_follow_a_forced_chunk() {
|
||||
use super::super::{Job, Queued, rank_again};
|
||||
use std::collections::BinaryHeap;
|
||||
let job = |x, z, order| Queued {
|
||||
priority: 0,
|
||||
at: Some((x, z)),
|
||||
bias: 0,
|
||||
order,
|
||||
job: Job::Generate(x, z),
|
||||
};
|
||||
let first_after = |forced: &HashSet<(i32, i32)>| {
|
||||
let ranges = Ranges {
|
||||
center: (0, 0),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
// (255, 254) lies nearer the origin than (258, 256), the
|
||||
// forced chunk's neighbour two chunks east.
|
||||
let mut queue = BinaryHeap::from(vec![job(255, 254, 1), job(258, 256, 2)]);
|
||||
rank_again(&mut queue, |x, z| ranges.priority(x, z));
|
||||
queue.pop().and_then(|q| q.at)
|
||||
};
|
||||
assert_eq!(first_after(&HashSet::new()), Some((255, 254)));
|
||||
assert_eq!(first_after(&[(256, 256)].into()), Some((258, 256)));
|
||||
}
|
||||
}
|
||||
@@ -1104,6 +1104,34 @@ pub fn tooltip_menu(
|
||||
);
|
||||
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
|
||||
}
|
||||
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: a keyboard-focused widget's tooltip.
|
||||
pub fn tooltip_focused(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = tooltip_size(atlas, lines);
|
||||
let (x, y) = focused_tooltip_position(
|
||||
(batch.width as i32, batch.height as i32),
|
||||
rect.map(|v| v as i32),
|
||||
(w.ceil() as i32, h.ceil() as i32),
|
||||
);
|
||||
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
|
||||
}
|
||||
|
||||
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: below, or above when it does not fit.
|
||||
fn focused_tooltip_position(screen: (i32, i32), rect: [i32; 4], size: (i32, i32)) -> (i32, i32) {
|
||||
let [left, top, width, height] = rect;
|
||||
let mut x = left + 3;
|
||||
let mut y = top + height + 4;
|
||||
if y + size.1 + 3 > screen.1 {
|
||||
y = top - size.1 - 4;
|
||||
}
|
||||
if x + size.0 > screen.0 {
|
||||
x = (left + width - size.0 - 3).max(4);
|
||||
}
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// A widget's tooltip while the controller's focus is on it (Horizon's: vanilla has no gamepad
|
||||
/// input, and the hidden pointer mid-widget would put it over the widget).
|
||||
pub fn tooltip_under(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
|
||||
@@ -2614,6 +2642,33 @@ mod tests {
|
||||
assert!(!delete_words(&mut v, 1));
|
||||
assert_eq!(v, "stone");
|
||||
}
|
||||
#[test]
|
||||
fn a_keyboard_focused_tooltip_uses_vanillas_widget_edges() {
|
||||
let screen = (320, 240);
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 40)),
|
||||
(10, 184)
|
||||
);
|
||||
// Exact fit below; one pixel taller goes above.
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 53)),
|
||||
(10, 184)
|
||||
);
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 54)),
|
||||
(10, 102)
|
||||
);
|
||||
// Right overflow aligns against the widget's right edge, not the screen's.
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [250, 100, 60, 20], (170, 40)),
|
||||
(137, 124)
|
||||
);
|
||||
assert_eq!(
|
||||
focused_tooltip_position((120, 240), [7, 160, 98, 20], (170, 40)),
|
||||
(4, 184)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pad_focused_tooltip_hangs_from_its_widgets_bottom_left() {
|
||||
let screen = (427.0, 240.0);
|
||||
|
||||
@@ -319,6 +319,12 @@ impl World {
|
||||
pub fn bottom_y(&self) -> i32 {
|
||||
self.layout().min_y()
|
||||
}
|
||||
/// `Entity.checkBelowWorld`'s floor, `Level.getMinY() - 64`: the level's own
|
||||
/// lowest Y (0 in the Nether and the End), not the chunks' widened column.
|
||||
/// An entity below it is out of the world.
|
||||
pub fn below_world_y(&self) -> f64 {
|
||||
f64::from(self.shape.min_y - 64)
|
||||
}
|
||||
/// One above the highest block Y the level's chunks hold.
|
||||
pub fn top_y(&self) -> i32 {
|
||||
self.layout().max_y()
|
||||
|
||||
@@ -862,7 +862,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
|
||||
self.st.fall_distance *= 0.5;
|
||||
}
|
||||
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
|
||||
if self.position().y < self.scene.world.below_world_y() {
|
||||
self.st.removed = true;
|
||||
}
|
||||
self.st.first_tick = false;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
|
||||
@@ -30,6 +31,8 @@ pub struct Banners {
|
||||
dyes: HashMap<Arc<str>, Arc<str>>,
|
||||
/// Item -> `provides_banner_patterns`, in tag order.
|
||||
provided: HashMap<Arc<str>, Vec<Arc<str>>>,
|
||||
/// Banner-pattern tag id -> its members, for a component that names a tag.
|
||||
pattern_tags: HashMap<String, Vec<Arc<str>>>,
|
||||
}
|
||||
|
||||
fn read_tag(root: &Path, name: &str, depth: u32) -> Vec<Arc<str>> {
|
||||
@@ -98,6 +101,31 @@ impl Banners {
|
||||
});
|
||||
}
|
||||
banners.no_item_required = read_tag(root, "no_item_required", 0);
|
||||
// Every tag, `pattern_item/globe` included, for a component naming one.
|
||||
let tags = root.join("data/minecraft/tags/banner_pattern");
|
||||
let mut dirs = vec![tags.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for path in fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
{
|
||||
if path.is_dir() {
|
||||
dirs.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "json")
|
||||
&& let Some(name) = path
|
||||
.strip_prefix(&tags)
|
||||
.ok()
|
||||
.and_then(|p| p.with_extension("").to_str().map(|s| s.replace('\\', "/")))
|
||||
{
|
||||
let members = read_tag(root, &name, 0);
|
||||
banners
|
||||
.pattern_tags
|
||||
.insert(format!("minecraft:{name}"), members);
|
||||
}
|
||||
}
|
||||
}
|
||||
let owned_defaults;
|
||||
let defaults = match defaults {
|
||||
Some(defaults) => Some(defaults),
|
||||
@@ -159,27 +187,88 @@ impl Banners {
|
||||
}
|
||||
/// `LoomMenu.isDyeItem`: the dye colour of a loom dye.
|
||||
pub fn loom_dye(&self, items: &Items, stack: &ItemStack) -> Option<Arc<str>> {
|
||||
let tagged = items
|
||||
if !items
|
||||
.item_tags
|
||||
.get("minecraft:loom_dyes")
|
||||
.is_some_and(|t| t.contains(&*stack.item));
|
||||
tagged
|
||||
.then(|| self.dyes.get(&stack.item).cloned())
|
||||
.flatten()
|
||||
.is_some_and(|t| t.contains(&*stack.item))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:dye").is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(Tag::String(color)) = data.and_then(|d| d.other("minecraft:dye")) {
|
||||
return Some(Arc::from(
|
||||
color.to_string_lossy().trim_start_matches("minecraft:"),
|
||||
));
|
||||
}
|
||||
self.dyes.get(&stack.item).cloned()
|
||||
}
|
||||
/// `LoomMenu.isPatternItem`.
|
||||
/// `LoomMenu.isPatternItem`: the item tag and a present
|
||||
/// `provides_banner_patterns` component (the stack's, else the item's).
|
||||
pub fn is_pattern_item(&self, items: &Items, stack: &ItemStack) -> bool {
|
||||
items
|
||||
if !items
|
||||
.item_tags
|
||||
.get("minecraft:loom_patterns")
|
||||
.is_some_and(|t| t.contains(&*stack.item))
|
||||
&& self.provided.contains_key(&stack.item)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
|
||||
return false;
|
||||
}
|
||||
data.is_some_and(|d| d.other("minecraft:provides_banner_patterns").is_some())
|
||||
|| self.provided.contains_key(&stack.item)
|
||||
}
|
||||
/// `LoomMenu.getSelectablePatterns`.
|
||||
pub fn selectable(&self, pattern_item: Option<&ItemStack>) -> Vec<Arc<str>> {
|
||||
match pattern_item {
|
||||
None => self.no_item_required.clone(),
|
||||
Some(stack) => self.provided.get(&stack.item).cloned().unwrap_or_default(),
|
||||
Some(stack) => {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
|
||||
return Vec::new();
|
||||
}
|
||||
match data.and_then(|d| d.other("minecraft:provides_banner_patterns")) {
|
||||
Some(tag) => self.provides(tag),
|
||||
None => self.provided.get(&stack.item).cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A `provides_banner_patterns` value: a pattern id, a tag, or a list of
|
||||
/// either (`HolderSet`'s saved forms).
|
||||
fn provides(&self, tag: &Tag) -> Vec<Arc<str>> {
|
||||
match tag {
|
||||
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
|
||||
Tag::List(values) => values
|
||||
.iter()
|
||||
.flat_map(|value| match value {
|
||||
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn provides_one(&self, value: &str) -> Vec<Arc<str>> {
|
||||
// `Identifier.parse`: an id or tag without a namespace is `minecraft`'s.
|
||||
let namespaced = |id: &str| {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
};
|
||||
match value.strip_prefix('#') {
|
||||
Some(tag) => self
|
||||
.pattern_tags
|
||||
.get(&namespaced(tag))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
None => vec![Arc::from(namespaced(value))],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +338,81 @@ mod tests {
|
||||
assert_eq!(&*b.selectable(Some(&item))[0], "minecraft:flower");
|
||||
}
|
||||
#[test]
|
||||
fn prepared_dye_component() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
|
||||
let b = Banners::load(&root);
|
||||
let mut dye = ItemStack::new("minecraft:white_dye", 1);
|
||||
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "white");
|
||||
dye.data_mut()
|
||||
.set_other("minecraft:dye", Some(Tag::String("red".into())));
|
||||
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "red");
|
||||
dye.data_mut().set_other(
|
||||
"!minecraft:dye",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(b.loom_dye(&items, &dye).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn prepared_pattern_component_presence() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
|
||||
let b = Banners::load(&root);
|
||||
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
|
||||
assert!(b.is_pattern_item(&items, &pattern));
|
||||
pattern.data_mut().set_other(
|
||||
"!minecraft:provides_banner_patterns",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(!b.is_pattern_item(&items, &pattern));
|
||||
}
|
||||
#[test]
|
||||
fn prepared_pattern_component_selection() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let b = Banners::load(&root);
|
||||
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
|
||||
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:globe");
|
||||
pattern.data_mut().set_other(
|
||||
"minecraft:provides_banner_patterns",
|
||||
Some(Tag::List(vec![Tag::String("minecraft:creeper".into())])),
|
||||
);
|
||||
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:creeper");
|
||||
// One id without its namespace, or a tag, as `HolderSet`'s codec reads them.
|
||||
pattern.data_mut().set_other(
|
||||
"minecraft:provides_banner_patterns",
|
||||
Some(Tag::String("creeper".into())),
|
||||
);
|
||||
assert_eq!(
|
||||
b.selectable(Some(&pattern)),
|
||||
[Arc::from("minecraft:creeper")]
|
||||
);
|
||||
pattern.data_mut().set_other(
|
||||
"minecraft:provides_banner_patterns",
|
||||
Some(Tag::String("#pattern_item/globe".into())),
|
||||
);
|
||||
assert_eq!(b.selectable(Some(&pattern)), [Arc::from("minecraft:globe")]);
|
||||
pattern.data_mut().set_other(
|
||||
"!minecraft:provides_banner_patterns",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(b.selectable(Some(&pattern)).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn banner_colour_comes_from_the_item() {
|
||||
assert_eq!(
|
||||
banner_color("minecraft:light_gray_banner"),
|
||||
|
||||
@@ -18,13 +18,28 @@ impl Traveller {
|
||||
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
|
||||
/// through it on the next tick.
|
||||
pub fn enter(&mut self, gateway: BlockPos) {
|
||||
self.state.set_inside(gateway, ENTITY_DELAY);
|
||||
self.enter_with_delay(gateway, ENTITY_DELAY);
|
||||
}
|
||||
|
||||
/// Enter with the traveller's `Entity.getDimensionChangingDelay`.
|
||||
pub fn enter_with_delay(&mut self, gateway: BlockPos, delay: i32) {
|
||||
self.state.set_inside(gateway, delay);
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal`: the entity goes through the gateway it entered
|
||||
/// (the End gateway has no transition time), or stays where it is.
|
||||
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
|
||||
self.state.handle(allowed, 0, ENTITY_DELAY)
|
||||
self.travel_with_delay(allowed, ENTITY_DELAY)
|
||||
}
|
||||
|
||||
/// Travel with the entity-specific portal cooldown.
|
||||
pub fn travel_with_delay(&mut self, allowed: bool, delay: i32) -> Option<BlockPos> {
|
||||
self.state.handle(allowed, 0, delay)
|
||||
}
|
||||
|
||||
/// `Entity.load` updates its counter without replacing the portal process.
|
||||
pub fn set_cooldown(&mut self, cooldown: i32) {
|
||||
self.state.cooldown = cooldown;
|
||||
}
|
||||
|
||||
/// Whether the entity's travel is over: it has no portal process left and
|
||||
@@ -171,6 +186,27 @@ mod tests {
|
||||
assert!(!traveller.is_done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_specific_cooldowns_refresh_then_expire_outside_the_gateway() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
for delay in [2, 10, 300] {
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter_with_delay(gateway, delay);
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), Some(gateway));
|
||||
assert_eq!(traveller.cooldown(), delay);
|
||||
// Continuing contact refreshes the counter, without another trip.
|
||||
traveller.enter_with_delay(gateway, delay);
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), None);
|
||||
assert_eq!(traveller.cooldown(), delay - 1);
|
||||
for _ in 1..delay {
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), None);
|
||||
}
|
||||
assert!(traveller.is_done());
|
||||
traveller.enter_with_delay(gateway, delay);
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), Some(gateway));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
|
||||
@@ -948,7 +948,7 @@ impl ItemEntity {
|
||||
if self.age != i16::MIN as i32 {
|
||||
self.age += 1;
|
||||
}
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
self.age < 6000 && self.position.y >= f64::from(world.shape.min_y - 64)
|
||||
}
|
||||
/// `Entity.makeStuckInBlock` as `WebBlock.entityInside` and
|
||||
/// `PowderSnowBlock.entityInside` call it for an item (which, unlike a
|
||||
|
||||
@@ -22,6 +22,8 @@ use horizon_world::{
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
mod boat_load;
|
||||
pub use boat_load::boat_from_nbt;
|
||||
mod projectile_fields;
|
||||
use projectile_fields::{load_projectile_entity_fields, put_projectile_entity_fields};
|
||||
mod saved_attributes;
|
||||
@@ -3472,7 +3474,7 @@ pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
|
||||
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = match kind(tag)?.as_str() {
|
||||
"minecraft:fireball" => ProjectileKind::LargeFireball {
|
||||
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
|
||||
power: i32::from(number(tag.get("ExplosionPower")).unwrap_or(1) as i8),
|
||||
},
|
||||
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
|
||||
"minecraft:dragon_fireball" => ProjectileKind::DragonFireball,
|
||||
@@ -3702,9 +3704,6 @@ pub fn vehicle_to_nbt(
|
||||
}
|
||||
.tag(raw)
|
||||
}
|
||||
pub fn boat_from_nbt(tag: &Tag) -> Option<(Vec3, Vec3, f32)> {
|
||||
Some((position(tag)?, motion(tag), rotation(tag)[0]))
|
||||
}
|
||||
/// `Entity.load`'s `OnGround` (`getBooleanOr`: `byteValue() != 0`) and
|
||||
/// `fall_distance` (`getDoubleOr`: any number's `doubleValue`), defaulting to
|
||||
/// false and 0.
|
||||
@@ -4435,6 +4434,30 @@ mod tests {
|
||||
assert_eq!(load(Tag::Short(7), Tag::Short(19)), (7, 19));
|
||||
}
|
||||
|
||||
/// `Axolotl.Variant.BY_ID` is `OutOfBoundsStrategy.ZERO`: an id outside
|
||||
/// 0..=4 is lucy (0). `Parrot.Variant.BY_ID` is `CLAMP`: it is the nearest
|
||||
/// end.
|
||||
#[test]
|
||||
fn an_unknown_axolotl_variant_is_lucy_and_a_parrots_is_clamped() {
|
||||
let variant = |species, id: &str, saved: i32| {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = id.into();
|
||||
ty.species = species;
|
||||
let types = [(id.into(), kind)].into();
|
||||
let raw = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[4.5, 220.0, 4.5])),
|
||||
("Variant", Tag::Int(saved)),
|
||||
]);
|
||||
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant
|
||||
};
|
||||
let axolotl = |saved| variant(crate::mobs::Species::Axolotl, "minecraft:axolotl", saved);
|
||||
let parrot = |saved| variant(crate::mobs::Species::Parrot, "minecraft:parrot", saved);
|
||||
assert_eq!([0, 3, 4, 5, 7, -1].map(axolotl), [0, 3, 4, 0, 0, 0]);
|
||||
assert_eq!([0, 3, 4, 5, 7, -1].map(parrot), [0, 3, 4, 4, 4, 0]);
|
||||
}
|
||||
|
||||
/// A mount keeps its body armour and a helmet (say
|
||||
/// `/item replace entity <horse> armor.head with minecraft:iron_helmet`)
|
||||
/// in their own slots through a save: vanilla holds both (`equipment.body`
|
||||
@@ -5916,8 +5939,7 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
|
||||
/// medium salmon or index 0; a missing bucket flag is false.
|
||||
fn read_variant(m: &mut Mob, tag: &Tag) {
|
||||
use crate::mobs::Species as S;
|
||||
// AbstractFish/Axolotl.readAdditionalSaveData: false when absent.
|
||||
// Tadpole.setFromBucket ignores the input.
|
||||
// AbstractFish/Axolotl: false when absent. Tadpole.setFromBucket ignores the input.
|
||||
if matches!(
|
||||
m.species(),
|
||||
S::Cod | S::Salmon | S::Pufferfish | S::TropicalFish | S::Axolotl
|
||||
@@ -5926,7 +5948,8 @@ fn read_variant(m: &mut Mob, tag: &Tag) {
|
||||
}
|
||||
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
|
||||
match m.species() {
|
||||
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
|
||||
S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8, // CLAMP
|
||||
S::Axolotl => m.extra.variant = ((int("Variant") as u32).min(5) % 5) as u8, // ZERO: lucy
|
||||
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
|
||||
S::Rabbit => {
|
||||
m.extra.variant = match int("RabbitType") {
|
||||
@@ -9433,6 +9456,25 @@ mod stand_fall_save_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LargeFireball.readAdditionalSaveData` reads `ExplosionPower` with
|
||||
/// `getByteOr`: an int tag is narrowed (`(byte)(value & 255)`), so 300 is
|
||||
/// 44 and 200 is -56.
|
||||
#[test]
|
||||
fn a_fireball_narrows_its_explosion_power_to_a_byte() {
|
||||
for (saved, expected) in [(Tag::Int(300), 44), (Tag::Int(200), -56), (Tag::Byte(4), 4)] {
|
||||
let input = Tag::compound([
|
||||
("id", string("minecraft:fireball")),
|
||||
("Pos", doubles(&[4.5, 205.0, 4.5])),
|
||||
("ExplosionPower", saved),
|
||||
]);
|
||||
let fireball = fireball_from_nbt(&input, None).unwrap();
|
||||
assert!(
|
||||
matches!(fireball.kind, ProjectileKind::LargeFireball { power } if power == expected),
|
||||
"{expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_stand_fall_distance_survives_save_without_raw_or_with_stale_raw() {
|
||||
let stale = Tag::compound([("fall_distance", Tag::Double(99.0_f64.to_bits()))]);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
use super::{loaded_motion, position, rotation};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `Entity.load` for a record with a saved position: clamp coordinates before
|
||||
/// rebuilding the box. Reject invalid positions; motion over 10 resets.
|
||||
pub fn boat_from_nbt(tag: &Tag) -> Option<(Vec3, Vec3, f32)> {
|
||||
let p = position(tag)?;
|
||||
let p = Vec3::new(
|
||||
p.x.clamp(-30_000_512.0, 30_000_512.0),
|
||||
p.y.clamp(-20_000_000.0, 20_000_000.0),
|
||||
p.z.clamp(-30_000_512.0, 30_000_512.0),
|
||||
);
|
||||
p.is_finite()
|
||||
.then(|| (p, loaded_motion(tag), rotation(tag)[0]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn load_clamps_each_position_axis_and_only_motion_over_ten() {
|
||||
let doubles = |v: [f64; 3]| Tag::List(v.map(|v| Tag::Double(v.to_bits())).to_vec());
|
||||
for (raw, expected) in [
|
||||
(
|
||||
[40_000_000.0, 30_000_000.0, -40_000_000.0],
|
||||
[30_000_512.0, 20_000_000.0, -30_000_512.0],
|
||||
),
|
||||
(
|
||||
[-40_000_000.0, -30_000_000.0, 40_000_000.0],
|
||||
[-30_000_512.0, -20_000_000.0, 30_000_512.0],
|
||||
),
|
||||
([1.25, -64.0, -3.5], [1.25, -64.0, -3.5]),
|
||||
] {
|
||||
let tag = Tag::compound([
|
||||
("Pos", doubles(raw)),
|
||||
("Motion", doubles([10.0, -10.0, 10.00001])),
|
||||
]);
|
||||
let (p, m, _) = boat_from_nbt(&tag).unwrap();
|
||||
assert_eq!(p.components(), expected);
|
||||
assert_eq!(m, Vec3::new(10.0, -10.0, 0.0));
|
||||
}
|
||||
let tag = Tag::compound([("Pos", doubles([0.0, f64::NAN, 0.0]))]);
|
||||
assert!(boat_from_nbt(&tag).is_none());
|
||||
}
|
||||
}
|
||||
@@ -5088,11 +5088,11 @@ impl Game<'_> {
|
||||
if removed && !creative {
|
||||
let held = self.held().cloned();
|
||||
self.mine_block_with_held(&facts.name, facts.hardness);
|
||||
let harvest = !facts.requires_tool
|
||||
|| self
|
||||
.res
|
||||
.items()
|
||||
.correct_for_drops(held.as_ref(), &facts.name);
|
||||
let harvest = self.res.items().has_correct_tool_for_drops(
|
||||
held.as_ref(),
|
||||
facts.requires_tool,
|
||||
&facts.name,
|
||||
);
|
||||
if harvest {
|
||||
// Block.playerDestroy: the mined statistic, the exhaustion and the drops.
|
||||
self.award_stat("mined", &facts.name, 1);
|
||||
@@ -7206,8 +7206,7 @@ impl Game<'_> {
|
||||
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
|
||||
self.particles.render(
|
||||
camera,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.particle_rotation(),
|
||||
alpha as f32,
|
||||
&|b| world.light(b[0], b[1], b[2]),
|
||||
&mut out,
|
||||
@@ -7226,8 +7225,7 @@ impl Game<'_> {
|
||||
let mut particle_culling = self.particle_occlusion_context(camera);
|
||||
self.particles.render_culled(
|
||||
camera,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.particle_rotation(),
|
||||
alpha as f32,
|
||||
pixel_focal_length,
|
||||
true,
|
||||
|
||||
@@ -15,6 +15,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::render::{DYE, DYE_NAMES},
|
||||
test_instance_block::Status,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
@@ -30,12 +31,15 @@ pub(super) const EFFECTS: [(&str, i32); 6] = [
|
||||
("strength", 3),
|
||||
("regeneration", 4),
|
||||
];
|
||||
const PAYMENT: [&str; 5] = [
|
||||
"minecraft:iron_ingot",
|
||||
"minecraft:gold_ingot",
|
||||
"minecraft:emerald",
|
||||
"minecraft:diamond",
|
||||
"minecraft:netherite_ingot",
|
||||
/// `BeaconScreen.extractBackground`'s payment row in drawing order, with each
|
||||
/// item's x: netherite, emerald, diamond, gold, iron. Not by value: the
|
||||
/// emerald is left of the diamond.
|
||||
const PAYMENT_ROW: [(&str, f32); 5] = [
|
||||
("minecraft:netherite_ingot", 20.0),
|
||||
("minecraft:emerald", 41.0),
|
||||
("minecraft:diamond", 63.0),
|
||||
("minecraft:gold_ingot", 86.0),
|
||||
("minecraft:iron_ingot", 108.0),
|
||||
];
|
||||
/// `BeaconMenu.PaymentSlot` and the player inventory, relative to the 176 origin.
|
||||
pub(super) fn slot_position(i: usize) -> Option<(f32, f32)> {
|
||||
@@ -73,6 +77,43 @@ fn upgrade_button() -> (f32, f32) {
|
||||
fn inside(p: (f32, f32), x: f32, y: f32, w: f32, h: f32) -> bool {
|
||||
p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h
|
||||
}
|
||||
/// `BeaconPowerButton.setEffect`'s tooltip: the effect's description, and for
|
||||
/// `BeaconUpgradePowerButton` (`createEffectDescription`) " II" after it.
|
||||
fn effect_tooltip(e: usize, upgraded: bool) -> Text {
|
||||
let effect = Text::translate(&format!("effect.minecraft.{}", EFFECTS[e].0), &[]);
|
||||
if upgraded {
|
||||
effect.append(" II")
|
||||
} else {
|
||||
effect
|
||||
}
|
||||
}
|
||||
/// `BeaconScreen`: every button's tooltip whichever one the cursor is over
|
||||
/// (`BeaconPowerButton`'s its effect, `BeaconSpriteScreenButton`'s its label),
|
||||
/// hovered whether or not it is active.
|
||||
fn hovered_tooltip(
|
||||
local: (f32, f32),
|
||||
primary: Option<usize>,
|
||||
buttons: &[(usize, f32, f32, bool)],
|
||||
) -> Option<Text> {
|
||||
for &(e, x, y, _) in buttons {
|
||||
if inside(local, x, y, 22.0, 22.0) {
|
||||
return Some(effect_tooltip(e, false));
|
||||
}
|
||||
}
|
||||
if let Some(p) = primary {
|
||||
let (x, y) = upgrade_button();
|
||||
if inside(local, x, y, 22.0, 22.0) {
|
||||
return Some(effect_tooltip(p, true));
|
||||
}
|
||||
}
|
||||
if inside(local, 164.0, 107.0, 22.0, 22.0) {
|
||||
return Some(Text::translate("gui.done", &[]));
|
||||
}
|
||||
if inside(local, 190.0, 107.0, 22.0, 22.0) {
|
||||
return Some(Text::translate("gui.cancel", &[]));
|
||||
}
|
||||
None
|
||||
}
|
||||
fn effect_id(name: &str) -> Option<&'static str> {
|
||||
let n = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
EFFECTS.iter().map(|(e, _)| *e).find(|e| *e == n)
|
||||
@@ -502,12 +543,12 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
}
|
||||
for (i, item) in PAYMENT.iter().rev().enumerate() {
|
||||
let x = [20.0, 41.0, 63.0, 86.0, 108.0][i];
|
||||
for (item, x) in PAYMENT_ROW {
|
||||
self.draw_item(batch, &ItemStack::new(item, 1), bx + x, by + 109.0);
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut tooltip = None;
|
||||
let buttons = buttons();
|
||||
let tooltip = hovered_tooltip(local, sel[0], &buttons);
|
||||
let button =
|
||||
|batch: &mut HudBatch, x: f32, y: f32, active: bool, selected: bool, icon: &str| {
|
||||
let hover = inside(local, x, y, 22.0, 22.0);
|
||||
@@ -535,9 +576,8 @@ impl Game<'_> {
|
||||
18.0,
|
||||
u32::MAX,
|
||||
);
|
||||
hover
|
||||
};
|
||||
for (e, x, y, primary) in buttons() {
|
||||
for (e, x, y, primary) in buttons {
|
||||
let tier = EFFECTS[e].1;
|
||||
let active = if primary {
|
||||
tier <= levels && tier < 4
|
||||
@@ -550,16 +590,12 @@ impl Game<'_> {
|
||||
sel[1] == Some(e)
|
||||
};
|
||||
let icon = format!("mob_effect/{}", EFFECTS[e].0);
|
||||
if button(batch, x, y, active, selected, &icon) {
|
||||
tooltip = Some(format!("effect.minecraft.{}", EFFECTS[e].0));
|
||||
}
|
||||
button(batch, x, y, active, selected, &icon);
|
||||
}
|
||||
if let Some(p) = sel[0] {
|
||||
let (x, y) = upgrade_button();
|
||||
let icon = format!("mob_effect/{}", EFFECTS[p].0);
|
||||
if button(batch, x, y, levels >= 4, sel[1] == Some(p), &icon) {
|
||||
tooltip = Some(format!("effect.minecraft.{}", EFFECTS[p].0));
|
||||
}
|
||||
button(batch, x, y, levels >= 4, sel[1] == Some(p), &icon);
|
||||
}
|
||||
button(
|
||||
batch,
|
||||
@@ -597,14 +633,8 @@ impl Game<'_> {
|
||||
}
|
||||
} else if let Some(stack) = stack_tip {
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
let text = self.translate(&key, &key);
|
||||
self.draw_tooltip_lines(
|
||||
batch,
|
||||
&[(horizon_client::text::Text::literal(text), u32::MAX)],
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
} else if let Some(text) = tooltip {
|
||||
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
|
||||
@@ -707,6 +737,66 @@ fn effect_box_hits(p: [i32; 3], range: f64, height: f64, lo: [f64; 3], hi: [f64;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `BeaconScreen.extractBackground`: netherite 20, emerald 41, diamond
|
||||
/// 41+22, gold 42+44, iron 42+66.
|
||||
#[test]
|
||||
fn the_payment_row_reads_netherite_emerald_diamond_gold_iron() {
|
||||
assert_eq!(
|
||||
PAYMENT_ROW,
|
||||
[
|
||||
("minecraft:netherite_ingot", 20.0),
|
||||
("minecraft:emerald", 41.0),
|
||||
("minecraft:diamond", 63.0),
|
||||
("minecraft:gold_ingot", 86.0),
|
||||
("minecraft:iron_ingot", 108.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
/// `BeaconUpgradePowerButton.createEffectDescription` appends " II"; the
|
||||
/// primary buttons' description is the bare effect.
|
||||
#[test]
|
||||
fn only_the_upgrade_button_names_the_second_level() {
|
||||
for e in 0..EFFECTS.len() {
|
||||
let primary = effect_tooltip(e, false).plain();
|
||||
let upgrade = effect_tooltip(e, true).plain();
|
||||
assert!(!primary.is_empty());
|
||||
assert!(!primary.ends_with(" II"));
|
||||
assert_eq!(upgrade, format!("{primary} II"));
|
||||
}
|
||||
}
|
||||
/// `BeaconScreenButton.setTooltip`: every button has one while hovered,
|
||||
/// and the sprite buttons' is their label whether or not they are active
|
||||
/// (the tick is disabled without a payment and a chosen power).
|
||||
#[test]
|
||||
fn every_hovered_beacon_button_shows_its_tooltip() {
|
||||
let at = |(x, y): (f32, f32)| (x + 11.0, y + 11.0);
|
||||
let name =
|
||||
|e: usize| horizon_client::lang::get(&format!("effect.minecraft.{}", EFFECTS[e].0));
|
||||
let buttons = buttons();
|
||||
for &(e, x, y, _) in &buttons {
|
||||
let tip = hovered_tooltip(at((x, y)), None, &buttons).unwrap_or_default();
|
||||
assert_eq!(tip.plain(), name(e), "button {e}");
|
||||
}
|
||||
let upgrade = at(upgrade_button());
|
||||
assert!(hovered_tooltip(upgrade, None, &buttons).is_none());
|
||||
assert_eq!(
|
||||
hovered_tooltip(upgrade, Some(0), &buttons).unwrap().plain(),
|
||||
format!("{} II", name(0))
|
||||
);
|
||||
assert_eq!(
|
||||
hovered_tooltip(at((164.0, 107.0)), None, &buttons)
|
||||
.unwrap_or_default()
|
||||
.plain(),
|
||||
horizon_client::lang::get("gui.done")
|
||||
);
|
||||
assert_eq!(
|
||||
hovered_tooltip(at((190.0, 107.0)), Some(0), &buttons)
|
||||
.unwrap_or_default()
|
||||
.plain(),
|
||||
horizon_client::lang::get("gui.cancel")
|
||||
);
|
||||
assert!(hovered_tooltip((0.0, 0.0), Some(0), &buttons).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn only_active_beacons_have_beams() {
|
||||
let mut b = Beacon::default();
|
||||
|
||||
@@ -131,6 +131,10 @@ pub(super) trait Editor {
|
||||
fn accepts(&self, _edit: usize, _current: &str, _cursor: usize, _c: char) -> bool {
|
||||
true
|
||||
}
|
||||
/// The box's tooltip (`EditBox.setTooltip`), if it has one.
|
||||
fn edit_tooltip(&self, _edit: usize) -> Option<Text> {
|
||||
None
|
||||
}
|
||||
/// Whether Enter is Done (a screen with its own `keyPressed`).
|
||||
fn enter_confirms(&self) -> bool {
|
||||
true
|
||||
@@ -171,6 +175,41 @@ impl BlockEditScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// `WidgetTooltipHolder.refreshTooltipForNextRenderPass`: hovering or keyboard focus shows it.
|
||||
fn edit_tooltip(
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
editor: &dyn Editor,
|
||||
items: &[Item],
|
||||
cursor: (f32, f32),
|
||||
focus: Option<usize>,
|
||||
keyboard: bool,
|
||||
virtual_pointer: bool,
|
||||
) -> Option<(Vec<Text>, [f32; 4], bool)> {
|
||||
let mut hovered = None;
|
||||
for item in items {
|
||||
if let Item::Edit { index, x, y, w } = item {
|
||||
let hover = !virtual_pointer && ui::hovered(cursor, *x, *y, *w, 20.0);
|
||||
let focused = keyboard && focus == Some(*index);
|
||||
if (hover || focused)
|
||||
&& let Some(tip) = editor.edit_tooltip(*index)
|
||||
{
|
||||
// `Tooltip.splitTooltip` uses 170 GUI pixels, including translated text.
|
||||
let tooltip = (
|
||||
atlas.font.split(&tip, 170.0),
|
||||
[*x, *y, *w, 20.0],
|
||||
focused && !hover,
|
||||
);
|
||||
// `GuiGraphicsExtractor.setTooltipForNextFrame`: the focused widget wins.
|
||||
if focused {
|
||||
return Some(tooltip);
|
||||
}
|
||||
hovered = Some(tooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
hovered
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LocalPlayer.open*Block`: the edit screen for the block entity at
|
||||
/// `pos`.
|
||||
@@ -243,6 +282,11 @@ impl Game<'_> {
|
||||
match outcome {
|
||||
Outcome::Stay => {}
|
||||
Outcome::Send(packet) => {
|
||||
// The integrated server is this game: vanilla's packet handler
|
||||
// applies the edit there, so a local game applies it here.
|
||||
if self.remote.is_none() {
|
||||
self.apply_local_editor_packet(&packet);
|
||||
}
|
||||
self.send(packet);
|
||||
self.close_block_editor();
|
||||
}
|
||||
@@ -256,6 +300,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The server side of an editor's packet, for the game that is its own
|
||||
/// server: `handleSetJigsawBlock` and its siblings run on the server.
|
||||
fn apply_local_editor_packet(&mut self, packet: &S) {
|
||||
if let S::SetJigsawBlock(p) = packet {
|
||||
self.apply_set_jigsaw_block(p);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_block_editor(&mut self, batch: &mut HudBatch) {
|
||||
let cursor = self.cursor;
|
||||
let Some(items) = self.with_block_editor(|s, e| {
|
||||
@@ -354,6 +406,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The focus navigator's pointer is hidden, not an actual mouse hover.
|
||||
let navigation_focus = self.pad_focus_rect();
|
||||
let focus = navigation_focus.map_or(screen.focus, |rect| {
|
||||
items.iter().find_map(|item| match item {
|
||||
Item::Edit { index, x, y, w } if [*x, *y, *w, 20.0] == rect => Some(*index),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some((lines, rect, focused)) = edit_tooltip(
|
||||
atlas,
|
||||
screen.editor.as_ref(),
|
||||
&items,
|
||||
cursor,
|
||||
focus,
|
||||
self.narration.input_type().is_keyboard(),
|
||||
navigation_focus.is_some(),
|
||||
) {
|
||||
if focused {
|
||||
ui::tooltip_focused(batch, atlas, &lines, rect);
|
||||
} else {
|
||||
ui::tooltip_menu(batch, atlas, &lines, cursor, rect);
|
||||
}
|
||||
}
|
||||
self.block_editor = Some(screen);
|
||||
}
|
||||
|
||||
@@ -643,6 +718,107 @@ pub(super) fn on_off(on: bool) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TooltipEditor;
|
||||
|
||||
impl Editor for TooltipEditor {
|
||||
fn edits(&mut self) -> &mut [EditBox] {
|
||||
&mut []
|
||||
}
|
||||
fn items(&self, _entity: &BlockEntity, _w: f32, _h: f32) -> Vec<Item> {
|
||||
Vec::new()
|
||||
}
|
||||
fn press(
|
||||
&mut self,
|
||||
_entity: &mut BlockEntity,
|
||||
_pos: Pos,
|
||||
_id: u8,
|
||||
_shift: bool,
|
||||
_alt: bool,
|
||||
) -> Outcome {
|
||||
Outcome::Stay
|
||||
}
|
||||
fn done(&mut self, _entity: &mut BlockEntity, _pos: Pos) -> Outcome {
|
||||
Outcome::Stay
|
||||
}
|
||||
fn cancel(&mut self, _entity: &mut BlockEntity) {}
|
||||
fn edit_tooltip(&self, edit: usize) -> Option<Text> {
|
||||
match edit {
|
||||
4 => Some(Text::literal(
|
||||
"A long explanation of the selection priority that must wrap without losing any of its words.",
|
||||
)),
|
||||
5 => Some(Text::literal("Placement priority")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_tooltips_follow_hover_keyboard_and_hidden_navigation_pointer() {
|
||||
let atlas = horizon_client::hud::HudAtlas::boot();
|
||||
let items = [
|
||||
Item::Edit {
|
||||
index: 0,
|
||||
x: 7.0,
|
||||
y: 20.0,
|
||||
w: 300.0,
|
||||
},
|
||||
Item::Edit {
|
||||
index: 5,
|
||||
x: 110.0,
|
||||
y: 160.0,
|
||||
w: 98.0,
|
||||
},
|
||||
Item::Edit {
|
||||
index: 4,
|
||||
x: 7.0,
|
||||
y: 160.0,
|
||||
w: 98.0,
|
||||
},
|
||||
];
|
||||
let get = |cursor, focus, keyboard, virtual_pointer| {
|
||||
edit_tooltip(
|
||||
&atlas,
|
||||
&TooltipEditor,
|
||||
&items,
|
||||
cursor,
|
||||
focus,
|
||||
keyboard,
|
||||
virtual_pointer,
|
||||
)
|
||||
};
|
||||
let (lines, rect, focused) = get((20.0, 165.0), None, false, false).unwrap();
|
||||
assert_eq!(rect, [7.0, 160.0, 98.0, 20.0]);
|
||||
assert!(!focused);
|
||||
assert!(lines.len() > 1);
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.all(|line| atlas.font.width_text(line) <= 170.0)
|
||||
);
|
||||
assert_eq!(
|
||||
lines.iter().map(Text::plain).collect::<Vec<_>>().join(" "),
|
||||
"A long explanation of the selection priority that must wrap without losing any of its words."
|
||||
);
|
||||
assert!(get((0.0, 0.0), Some(4), true, false).unwrap().2);
|
||||
assert!(get((0.0, 0.0), Some(4), false, false).is_none());
|
||||
// A real hover uses the menu positioner, even when the field has keyboard focus.
|
||||
assert!(!get((20.0, 165.0), Some(4), true, false).unwrap().2);
|
||||
// Keyboard/controller navigation puts a hidden pointer in the field's center.
|
||||
assert!(get((56.0, 170.0), Some(4), true, true).unwrap().2);
|
||||
assert!(get((56.0, 170.0), None, true, true).is_none());
|
||||
assert!(get((20.0, 25.0), Some(0), true, false).is_none());
|
||||
assert!(get((105.0, 180.0), None, false, false).is_none());
|
||||
// A keyboard-focused field overrides a different field's real hover.
|
||||
let (focused_lines, focused_rect, focused) =
|
||||
get((120.0, 165.0), Some(4), true, false).unwrap();
|
||||
assert_eq!(
|
||||
focused_lines.iter().map(Text::plain).collect::<Vec<_>>(),
|
||||
lines.iter().map(Text::plain).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(focused_rect, rect);
|
||||
assert!(focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_characters_follow_screen_is_valid_character_for_name() {
|
||||
assert!(valid_character_for_name("", 'a', 0));
|
||||
@@ -667,4 +843,46 @@ mod tests {
|
||||
assert_eq!(cycle(&[1, 2, 3], 9, 1), 1);
|
||||
assert_eq!(cycle(&[1, 2, 3], 9, -1), 2);
|
||||
}
|
||||
|
||||
/// A single-player jigsaw edit is applied by the game that is its own
|
||||
/// server, as `handleSetJigsawBlock` does there, and marks the chunk.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_local_jigsaw_edit_applies_and_marks_the_chunk() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
let pos = (2, 4, 3);
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:jigsaw")
|
||||
.unwrap();
|
||||
game.world.set(pos.0, pos.1, pos.2, state).unwrap();
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(pos, BlockEntity::JigsawBlock(Box::default()));
|
||||
game.world.modified.clear();
|
||||
let packet = S::SetJigsawBlock(horizon_protocol::packets::play::SetJigsawBlock {
|
||||
pos: [2, 4, 3],
|
||||
name: "minecraft:test".into(),
|
||||
target: "minecraft:test".into(),
|
||||
pool: "minecraft:empty".into(),
|
||||
final_state: "minecraft:air".into(),
|
||||
joint: "aligned".into(),
|
||||
selection_priority: 1,
|
||||
placement_priority: 2,
|
||||
});
|
||||
|
||||
game.block_editor_outcome(Outcome::Send(packet));
|
||||
|
||||
let Some(BlockEntity::JigsawBlock(b)) = game.world.block_entities.get(&pos) else {
|
||||
panic!("no jigsaw");
|
||||
};
|
||||
assert_eq!(&*b.name, "minecraft:test", "the edit landed");
|
||||
assert_eq!((b.selection_priority, b.placement_priority), (1, 2));
|
||||
assert!(
|
||||
game.world.modified.contains(&(0, 0)),
|
||||
"the edit left the chunk unmarked"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -576,6 +576,10 @@ impl Game<'_> {
|
||||
let full = count == b.max_passengers();
|
||||
let delta = b.delta_rotation;
|
||||
let boat_box = b.bounds();
|
||||
let ridden = horizon_client::motion::RiddenBoat {
|
||||
under_water: b.under_water(),
|
||||
max_y: boat_box.max_position().y,
|
||||
};
|
||||
let seats: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -608,6 +612,7 @@ impl Game<'_> {
|
||||
for (j, offset, at, previous, facing) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
m.seated(id, offset, boat_box);
|
||||
m.fluids.boat = Some((id, ridden));
|
||||
m.position = at;
|
||||
m.previous = previous;
|
||||
m.velocity = Vec3::ZERO;
|
||||
|
||||
@@ -565,7 +565,10 @@ impl Game<'_> {
|
||||
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
|
||||
return None;
|
||||
}
|
||||
let above = self.res.facts().get(self.world.block(x, y + 1, z));
|
||||
let above = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(x, y.wrapping_add(1), z));
|
||||
// FluidState.getHeight: 1 under the same fluid, else amount / 9.
|
||||
let height = if above.fluid == f.fluid {
|
||||
1.0
|
||||
@@ -597,7 +600,7 @@ impl Game<'_> {
|
||||
return y as f32 + height;
|
||||
}
|
||||
}
|
||||
(y1 + 1) as f32
|
||||
(i64::from(y1) + 1) as f32
|
||||
}
|
||||
fn boat_status(&self, b: &mut Boat) -> Status {
|
||||
let bb = b.bounds();
|
||||
@@ -660,12 +663,12 @@ impl Game<'_> {
|
||||
);
|
||||
let (g_lo, g_hi) = (ground.min_position(), ground.max_position());
|
||||
let (x0, x1, y0, y1, z0, z1) = (
|
||||
g_lo.x.floor() as i32 - 1,
|
||||
g_hi.x.ceil() as i32 + 1,
|
||||
g_lo.y.floor() as i32 - 1,
|
||||
g_hi.y.ceil() as i32 + 1,
|
||||
g_lo.z.floor() as i32 - 1,
|
||||
g_hi.z.ceil() as i32 + 1,
|
||||
(g_lo.x.floor() - 1.0) as i32,
|
||||
(g_hi.x.ceil() + 1.0) as i32,
|
||||
(g_lo.y.floor() - 1.0) as i32,
|
||||
(g_hi.y.ceil() + 1.0) as i32,
|
||||
(g_lo.z.floor() - 1.0) as i32,
|
||||
(g_hi.z.ceil() + 1.0) as i32,
|
||||
);
|
||||
let slab = VoxelShape::from_box(ground).unwrap_or_else(|_| VoxelShape::empty());
|
||||
let mut scene = SceneCollisions {
|
||||
@@ -740,7 +743,7 @@ impl Game<'_> {
|
||||
if moved_y < 0.0 {
|
||||
let below = self.res.facts().get(self.world.block(
|
||||
b.position.x.floor() as i32,
|
||||
b.position.y.floor() as i32 - 1,
|
||||
(b.position.y.floor() as i32).wrapping_sub(1),
|
||||
b.position.z.floor() as i32,
|
||||
));
|
||||
if below.fluid != Fluid::Water {
|
||||
@@ -980,7 +983,7 @@ impl Game<'_> {
|
||||
if driven.contains(&id) {
|
||||
self.boats[i].wear_off_hurt();
|
||||
}
|
||||
if self.boat_fire_tick(i) {
|
||||
if self.boat_fire_tick(i) || self.boat_below_world(i) {
|
||||
break 'boat;
|
||||
}
|
||||
}
|
||||
@@ -1099,9 +1102,12 @@ impl Game<'_> {
|
||||
));
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let mut columns = Vec::new();
|
||||
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
|
||||
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
|
||||
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
|
||||
// Entity.checkInsideBlocks floors both ends of the deflated box.
|
||||
// Narrowing ceil(max) before subtracting one wraps at i32::MIN,
|
||||
// turning a tiny box into a scan of the entire integer Y domain.
|
||||
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
|
||||
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
|
||||
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
|
||||
if let Some(column) = bubble_column_at(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
|
||||
@@ -107,4 +107,42 @@ mod tests {
|
||||
.sum();
|
||||
assert_eq!(spilled, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn saved_deep_end_boat_loads_then_is_discarded_and_extreme_scans_terminate() {
|
||||
use super::super::Boat;
|
||||
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut g = crate::game::native_machines::tests::server_game();
|
||||
g.world.shape.min_y = 0;
|
||||
let doubles = |v: &[f64]| Tag::List(v.iter().map(|d| Tag::Double(d.to_bits())).collect());
|
||||
let tag = Tag::compound([
|
||||
(
|
||||
"Pos",
|
||||
doubles(&[-77.75688596047263, -2146769274.8915758, -55.6734678353711]),
|
||||
),
|
||||
("Motion", doubles(&[-2e-323, -13104.999899947774, -2e-323])),
|
||||
]);
|
||||
g.load_boat(tag, None, "minecraft:oak_boat");
|
||||
assert_eq!(g.boats.len(), 1);
|
||||
assert_eq!(g.boats[0].position.y, -20_000_000.0);
|
||||
assert_eq!(g.boats[0].velocity, Vec3::new(-2e-323, 0.0, -2e-323));
|
||||
g.player = Some(PlayerMotion::new(g.motion, Vec3::new(-77.75, 64.0, -55.67)).unwrap());
|
||||
g.world
|
||||
.chunks
|
||||
.insert((-5, -4), std::sync::Arc::new(Chunk::empty(-5, -4)));
|
||||
g.update_chunk_tickets();
|
||||
assert!(g.entity_ticking_area()(-78, -56));
|
||||
g.tick_boats();
|
||||
assert!(g.boats.is_empty());
|
||||
for y in [f64::from(i32::MIN) - 1.0, f64::from(i32::MAX) + 1.0] {
|
||||
g.boats
|
||||
.push(Boat::new(2, "oak".into(), Vec3::new(0.5, y, 0.5), 0.0));
|
||||
assert!(g.boat_bubble_columns(0).is_empty());
|
||||
assert_eq!(g.boat_ground_friction(g.boats[0].bounds()), 0.0);
|
||||
g.boats.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let _guard = servers::lock();
|
||||
let (mut list, mut hidden) = servers::load_all(&file);
|
||||
let mut found = false;
|
||||
for entry in list.iter_mut().filter(|s| &s.ip == address) {
|
||||
|
||||
@@ -37,6 +37,14 @@ struct Entry {
|
||||
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
|
||||
direct: Option<Text>,
|
||||
}
|
||||
struct PendingAttacker {
|
||||
damage_type: String,
|
||||
attacker: (u32, Text),
|
||||
item: Option<Text>,
|
||||
living: bool,
|
||||
credit: bool,
|
||||
}
|
||||
|
||||
/// What is stuck in a player's body: `LivingEntity.DATA_ARROW_COUNT_ID` and
|
||||
/// `DATA_STINGER_COUNT_ID` with the timers that pull them out
|
||||
/// (`removeArrowTime`, `removeStingerTime`). Kept with the combat record,
|
||||
@@ -87,7 +95,7 @@ pub(super) struct CombatTracker {
|
||||
in_combat: bool,
|
||||
/// Attackers of damage dealt this tick, claimed by the matching
|
||||
/// `RecordedDamage` event.
|
||||
pending: Vec<(String, (u32, Text), Option<Text>)>,
|
||||
pending: Vec<PendingAttacker>,
|
||||
/// `pending` for a direct entity with no causing entity: only its name.
|
||||
pending_direct: Vec<(String, Text)>,
|
||||
/// The lengths of `pending` and `pending_direct` before the first
|
||||
@@ -107,9 +115,15 @@ pub(super) struct CombatTracker {
|
||||
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
|
||||
/// its entity id and display name, the blast's causing entity.
|
||||
pub(super) blast_owner: Option<(u32, Text)>,
|
||||
/// `Some(None)` is an explicitly ownerless crystal/cart DamageSource.
|
||||
pub(super) explicit_explosion_cause: Option<Option<super::explosion::ExplosionCause>>,
|
||||
pub(super) explicit_explosion_direct: Option<Text>,
|
||||
/// The guest behind `blast_owner`, when that player is a LAN guest; the
|
||||
/// host's own blast leaves it None.
|
||||
pub(super) blast_guest: Option<u32>,
|
||||
/// `blast_owner` is a player (`getIndirectSourceEntity() instanceof
|
||||
/// Player`), not a mob whose UUID the TNT's `owner` names.
|
||||
pub(super) blast_owner_player: bool,
|
||||
pub(super) projectile_attacker: Option<(u32, Text)>,
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
@@ -319,9 +333,32 @@ impl Game<'_> {
|
||||
item: Option<Text>,
|
||||
) {
|
||||
self.combat.mark();
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name), item));
|
||||
self.combat.pending.push(PendingAttacker {
|
||||
damage_type: damage_type.to_owned(),
|
||||
attacker: (id, name),
|
||||
item,
|
||||
living: true,
|
||||
credit: true,
|
||||
});
|
||||
}
|
||||
/// A non-living or already removed cause remains in the damage source,
|
||||
/// but cannot resolve as an EntityReference for kill credit.
|
||||
pub(super) fn note_uncredited_attacker(
|
||||
&mut self,
|
||||
damage_type: &str,
|
||||
id: u32,
|
||||
name: Text,
|
||||
item: Option<Text>,
|
||||
living: bool,
|
||||
) {
|
||||
self.combat.mark();
|
||||
self.combat.pending.push(PendingAttacker {
|
||||
damage_type: damage_type.to_owned(),
|
||||
attacker: (id, name),
|
||||
item,
|
||||
living,
|
||||
credit: false,
|
||||
});
|
||||
}
|
||||
/// The `DamageSource.directEntity` of the next `damage_type` damage when
|
||||
/// nothing caused it (a splash potion nobody threw): the death message
|
||||
@@ -358,14 +395,20 @@ impl Game<'_> {
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let (attacker, item) = self
|
||||
let (attacker, item, living, credit) = self
|
||||
.combat
|
||||
.pending
|
||||
.iter()
|
||||
.position(|(t, _, _)| *t == name)
|
||||
.position(|p| p.damage_type == name)
|
||||
.map(|i| {
|
||||
let (_, attacker, item) = self.combat.pending.remove(i);
|
||||
(Some(attacker), item)
|
||||
let PendingAttacker {
|
||||
attacker,
|
||||
item,
|
||||
living,
|
||||
credit,
|
||||
..
|
||||
} = self.combat.pending.remove(i);
|
||||
(Some(attacker), item, living, credit)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let direct = match attacker {
|
||||
@@ -380,8 +423,10 @@ impl Game<'_> {
|
||||
let (fall_location, fall_distance) = self.fall_location();
|
||||
let now = self.world.game_time;
|
||||
if let Some(a) = &attacker {
|
||||
self.combat.last_hurt_by = Some((a.clone(), now));
|
||||
if !self.combat.in_combat {
|
||||
if living {
|
||||
self.combat.last_hurt_by = credit.then(|| (a.clone(), now));
|
||||
}
|
||||
if living && !self.combat.in_combat {
|
||||
self.combat.in_combat = true;
|
||||
}
|
||||
}
|
||||
@@ -480,7 +525,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// The display component of a custom-named held stack.
|
||||
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
|
||||
pub(super) fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
let mut stack = m.extra.main_hand_stack()?.clone();
|
||||
if !has_custom_name(&stack) {
|
||||
@@ -500,7 +545,7 @@ impl Game<'_> {
|
||||
/// `main_hand_custom_name` of a player who is not in place: the guest
|
||||
/// `conn` between turns, or none for the host while a guest acts (their
|
||||
/// hotbar slot is kept by the swap).
|
||||
fn player_main_hand_custom_name(&self, player: Option<u32>) -> Option<Text> {
|
||||
pub(super) fn player_main_hand_custom_name(&self, player: Option<u32>) -> Option<Text> {
|
||||
if player == self.lan_acting {
|
||||
return self.main_hand_custom_name();
|
||||
}
|
||||
@@ -895,7 +940,7 @@ mod held_item_tests {
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod noted_tests {
|
||||
use super::CombatTracker;
|
||||
use super::{CombatTracker, PendingAttacker};
|
||||
use horizon_client::text::Text;
|
||||
|
||||
/// A canceled hit forgets what was noted for it, also before its hurt
|
||||
@@ -905,11 +950,13 @@ mod noted_tests {
|
||||
let mut c = CombatTracker::default();
|
||||
let arrow = |c: &mut CombatTracker, id| {
|
||||
c.mark();
|
||||
c.pending.push((
|
||||
"minecraft:arrow".into(),
|
||||
(id, Text::literal("skeleton")),
|
||||
None,
|
||||
));
|
||||
c.pending.push(PendingAttacker {
|
||||
damage_type: "minecraft:arrow".into(),
|
||||
attacker: (id, Text::literal("skeleton")),
|
||||
item: None,
|
||||
living: true,
|
||||
credit: true,
|
||||
});
|
||||
};
|
||||
arrow(&mut c, 1);
|
||||
c.forget_noted(false);
|
||||
@@ -918,7 +965,10 @@ mod noted_tests {
|
||||
c.pending_direct
|
||||
.push(("minecraft:arrow".into(), Text::literal("arrow")));
|
||||
c.forget_noted(true);
|
||||
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1]);
|
||||
assert_eq!(
|
||||
c.pending.iter().map(|p| p.attacker.0).collect::<Vec<_>>(),
|
||||
[1]
|
||||
);
|
||||
assert!(c.pending_direct.is_empty());
|
||||
// A hit noted and landed, then one noted and canceled: the landed
|
||||
// one keeps its attacker.
|
||||
@@ -927,7 +977,10 @@ mod noted_tests {
|
||||
c.forget_noted(false);
|
||||
arrow(&mut c, 4);
|
||||
c.forget_noted(true);
|
||||
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1, 3]);
|
||||
assert_eq!(
|
||||
c.pending.iter().map(|p| p.attacker.0).collect::<Vec<_>>(),
|
||||
[1, 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
@@ -1010,7 +1063,7 @@ mod guest_hit_tests {
|
||||
game.combat.pending.clear();
|
||||
game.guest_mob_event(5, hit);
|
||||
let noted: Vec<u32> = (game.combat.pending.iter())
|
||||
.map(|(_, (id, _), _)| *id)
|
||||
.map(|pending| pending.attacker.0)
|
||||
.collect();
|
||||
assert_eq!(noted, [5]);
|
||||
assert_eq!(game.combat.melee_mob, None, "the hit used it up");
|
||||
|
||||
@@ -13,6 +13,7 @@ pub(in crate::game) mod items;
|
||||
mod misc;
|
||||
mod place;
|
||||
mod players;
|
||||
mod position_args;
|
||||
pub(super) mod pregen;
|
||||
mod profiles;
|
||||
mod raid;
|
||||
@@ -517,22 +518,6 @@ impl Game<'_> {
|
||||
pub(super) fn position_arg(&self, ctx: &Context, name: &str) -> Option<[f64; 3]> {
|
||||
self.coords(ctx, name).map(|c| self.resolve_coords(&c))
|
||||
}
|
||||
/// BlockPosArgument.getLoadedBlockPos.
|
||||
pub(super) fn block_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
|
||||
// BlockPos.containing(getPosition)
|
||||
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
}
|
||||
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
|
||||
return Err(err("argument.pos.outofworld", &[]));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
fn int(ctx: &Context, name: &str) -> Option<i32> {
|
||||
match ctx.get(name) {
|
||||
Some(Arg::Int(v)) => Some(*v),
|
||||
@@ -3794,7 +3779,7 @@ impl Game<'_> {
|
||||
vec![Target::Player]
|
||||
};
|
||||
let pos = if ctx.get("pos").is_some() {
|
||||
self.block_pos_arg(ctx, "pos")?
|
||||
self.spawnable_pos_arg(ctx, "pos")?
|
||||
} else {
|
||||
let p = self.source_position();
|
||||
[
|
||||
@@ -3855,7 +3840,7 @@ impl Game<'_> {
|
||||
}
|
||||
fn cmd_setworldspawn(&mut self, ctx: &Context) -> R<i32> {
|
||||
let pos = if ctx.get("pos").is_some() {
|
||||
self.block_pos_arg(ctx, "pos")?
|
||||
self.spawnable_pos_arg(ctx, "pos")?
|
||||
} else {
|
||||
let p = self.source_position();
|
||||
[
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Block position argument validation for command execution.
|
||||
use super::{Context, Game, R, err};
|
||||
|
||||
impl Game<'_> {
|
||||
/// BlockPosArgument.getLoadedBlockPos.
|
||||
pub(in crate::game) fn block_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
|
||||
// BlockPos.containing(getPosition)
|
||||
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
}
|
||||
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
|
||||
return Err(err("argument.pos.outofworld", &[]));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
/// BlockPosArgument.getSpawnablePos: no loaded-chunk or build-height check,
|
||||
/// only `Level.isInSpawnableBounds` (upper bounds exclusive).
|
||||
pub(in crate::game) fn spawnable_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
|
||||
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
|
||||
if !(-30_000_000..30_000_000).contains(&p[0])
|
||||
|| !(-30_000_000..30_000_000).contains(&p[2])
|
||||
|| !(-20_000_000..20_000_000).contains(&p[1])
|
||||
{
|
||||
return Err(err("argument.pos.outofbounds", &[]));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod mob_geometry;
|
||||
mod mob_group;
|
||||
mod mob_islands;
|
||||
mod prep;
|
||||
mod projectile_targets;
|
||||
mod projectile_world;
|
||||
mod pufferfish_player;
|
||||
mod spawn_eggs;
|
||||
@@ -796,7 +797,7 @@ impl Game<'_> {
|
||||
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
|
||||
let info = me.res.items().get(&stack.item)?;
|
||||
if info.equip_slot.is_none()
|
||||
|| !info.equip_damage_on_hurt
|
||||
|| !horizon_client::item_sync::damage_on_hurt(stack, info.equip_damage_on_hurt)
|
||||
|| info.max_damage == 0
|
||||
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|
||||
|| (fire && me.res.items().resists_fire(stack, name))
|
||||
@@ -4919,38 +4920,7 @@ impl Game<'_> {
|
||||
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
|
||||
}
|
||||
}
|
||||
/// The boats, minecarts and primed TNT a projectile can strike
|
||||
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`).
|
||||
pub(super) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let boats = self.boats.iter().map(|b| (Who::Mob(b.id), b.bounds()));
|
||||
let carts = self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds()));
|
||||
let tnts = self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds()));
|
||||
boats.chain(carts).chain(tnts).collect()
|
||||
}
|
||||
/// What a projectile can hit: the mobs, the player, the dragon's parts and
|
||||
/// the End crystals (under reserved ids, game/dragon.rs).
|
||||
pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (Who::Mob(m.id), m.bounds()))
|
||||
.collect();
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
{
|
||||
targets.push((Who::Player, p.bounds));
|
||||
}
|
||||
targets.extend(
|
||||
self.special_targets()
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
// Boats, minecarts and primed TNT are pickable for every projectile,
|
||||
// so every kind shares this one list.
|
||||
targets.extend(self.pickable_object_targets());
|
||||
targets
|
||||
}
|
||||
/// `Level.isRainingAt` for arrows.
|
||||
pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
|
||||
move |p: BlockPos| self.raining_at(p)
|
||||
}
|
||||
@@ -5201,6 +5171,24 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if p.arrow.is_some()
|
||||
&& let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} = hit
|
||||
&& self.item_frames.iter().any(|f| f.id == id)
|
||||
{
|
||||
// AbstractArrow.onHitEntity on an item frame: spent when the
|
||||
// frame took the hit, turned back when a fixed one refused it.
|
||||
let by_player = p.owner == Some(Who::Player);
|
||||
let hurt = self.projectile_hurts_frame(id, by_player, by_player && self.creative);
|
||||
let outcome = if hurt {
|
||||
EntityHit::Hurt
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
};
|
||||
self.finish_arrow_hit(p.clone(), outcome);
|
||||
return Ok(());
|
||||
}
|
||||
if p.arrow.is_some()
|
||||
&& let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
@@ -8832,6 +8820,9 @@ mod chorus_projectile_drop_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frame_projectile_tests.rs"]
|
||||
mod frame_projectile_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "loaded_arrow_shot_tests.rs"]
|
||||
mod loaded_arrow_shot_tests;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! What a projectile can strike: the mobs, the player, the dragon's parts and
|
||||
//! End crystals, and the non-living entities that are pickable (boats,
|
||||
//! minecarts, item frames).
|
||||
use super::super::Game;
|
||||
use horizon_client::mobs::Who;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The boats, minecarts, primed TNT and item frames a projectile can strike
|
||||
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`,
|
||||
/// `BlockAttachedEntity.isPickable`).
|
||||
pub(in crate::game) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| (Who::Mob(b.id), b.bounds()))
|
||||
.collect();
|
||||
targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
|
||||
targets.extend(self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds())));
|
||||
targets.extend(self.item_frames.iter().map(|f| {
|
||||
let (lo, hi) = f.bounds();
|
||||
(
|
||||
Who::Mob(f.id),
|
||||
Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
),
|
||||
)
|
||||
}));
|
||||
targets
|
||||
}
|
||||
/// What a projectile can hit: the mobs, the player, the dragon's parts and
|
||||
/// the End crystals (under reserved ids, game/dragon.rs), and the vehicles
|
||||
/// and item frames.
|
||||
pub(in crate::game) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (Who::Mob(m.id), m.bounds()))
|
||||
.collect();
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
{
|
||||
targets.push((Who::Player, p.bounds));
|
||||
}
|
||||
targets.extend(
|
||||
self.special_targets()
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
// Boats, minecarts, primed TNT and item frames are pickable for every
|
||||
// projectile, so every kind shares this one list.
|
||||
targets.extend(self.pickable_object_targets());
|
||||
targets
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
use super::*;
|
||||
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn below_world_y(&self) -> f64 {
|
||||
self.w.world.below_world_y()
|
||||
}
|
||||
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
|
||||
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
|
||||
let state = self.w.world.block(p.x, p.y, p.z);
|
||||
|
||||
@@ -68,17 +68,13 @@ fn block_break_speed_factor(value: Option<f64>) -> f32 {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
|
||||
/// and mining fatigue.
|
||||
pub(super) fn modify_destroy_speed(&mut self, mut speed: f32) -> f32 {
|
||||
if speed > 1.0 {
|
||||
speed += self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|player| player.attribute("minecraft:mining_efficiency").ok())
|
||||
.unwrap_or(0.0) as f32;
|
||||
}
|
||||
speed * self.potions.effects.mining_speed_factor()
|
||||
/// `Attributes.MINING_EFFICIENCY` (efficiency), which `Player.getDestroySpeed`
|
||||
/// adds to a tool speed above 1: 0 without a player or attribute.
|
||||
pub(super) fn mining_efficiency(&mut self) -> f32 {
|
||||
self.player
|
||||
.as_mut()
|
||||
.and_then(|player| player.attribute("minecraft:mining_efficiency").ok())
|
||||
.unwrap_or(0.0) as f32
|
||||
}
|
||||
/// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor:
|
||||
/// 1.0 by default, so a missing player or attribute changes nothing.
|
||||
|
||||
@@ -1535,8 +1535,15 @@ impl Game<'_> {
|
||||
if self.restart_portal_cooldown(id) {
|
||||
continue;
|
||||
}
|
||||
self.end.gateways.entry(id).or_default().enter(pos);
|
||||
let delay = self.entity_portal_delay(id);
|
||||
self.end
|
||||
.gateways
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.enter_with_delay(pos, delay);
|
||||
}
|
||||
// ThrowableProjectile applies the gateway's block effect before Entity.tick.
|
||||
self.handle_end_gateway_portals_phase(true);
|
||||
}
|
||||
|
||||
/// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's
|
||||
@@ -1558,7 +1565,11 @@ impl Game<'_> {
|
||||
/// `Entity.handlePortal` for the entities in an End gateway: they go
|
||||
/// through to the gateway's exit, where they wait for its chunks.
|
||||
pub(super) fn handle_end_gateway_portals(&mut self) {
|
||||
// Nobody in a gateway (nearly always): nothing to look up.
|
||||
self.handle_end_gateway_portals_phase(false);
|
||||
}
|
||||
|
||||
fn handle_end_gateway_portals_phase(&mut self, after_move: bool) {
|
||||
// Nobody in a gateway: nothing to look up.
|
||||
if self.remote.is_some() || self.dim != Dim::End || self.end.gateways.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -1606,11 +1617,25 @@ impl Game<'_> {
|
||||
if !ticking {
|
||||
continue;
|
||||
}
|
||||
// Only an entity in a gateway has a state: skip the others before
|
||||
// the per-id lookups below, which walk the entity lists.
|
||||
if !self.end.gateways.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
let is_pearl = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.any(|p| p.id == id && matches!(p.kind, ProjectileKind::EnderPearl));
|
||||
if is_pearl != after_move {
|
||||
continue;
|
||||
}
|
||||
let delay = self.entity_portal_delay(id);
|
||||
let Some(state) = self.end.gateways.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
// The End gateway has no transition time (Portal.getPortalTransitionTime).
|
||||
if let Some(entry) = state.travel(allowed) {
|
||||
if let Some(entry) = state.travel_with_delay(allowed, delay) {
|
||||
leaving.push((id, entry));
|
||||
}
|
||||
if state.is_done() {
|
||||
@@ -1939,3 +1964,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "gateway_cooldown_tests.rs"]
|
||||
mod cooldown_tests;
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
Game,
|
||||
entity::{Blast, Entity, EntityHandle, with_entity, with_entity_mut},
|
||||
};
|
||||
|
||||
use horizon_client::{
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
|
||||
};
|
||||
@@ -23,6 +24,16 @@ use horizon_world::{
|
||||
};
|
||||
use std::{cmp::Ordering, io};
|
||||
|
||||
/// The causing entity of an explicit explosion damage source. Minecart ignition
|
||||
/// keeps this in memory only, just like `MinecartTNT.ignitionSource`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct ExplosionCause {
|
||||
pub entity: (u32, Text),
|
||||
pub player: bool,
|
||||
pub guest: Option<u32>,
|
||||
pub species: Option<horizon_client::mobs::Species>,
|
||||
}
|
||||
|
||||
/// `Level.clip` with `ClipContext.Block.COLLIDER`, no fluids and the
|
||||
/// collision context of the entity whose exposure is measured.
|
||||
struct ColliderRays<'a, 'r> {
|
||||
@@ -171,6 +182,10 @@ fn blast_direction(center: [f64; 3], origin: [f64; 3]) -> [f64; 3] {
|
||||
struct ExplosionHookContext<'a> {
|
||||
center: [f64; 3],
|
||||
blocks: &'a [[i32; 3]],
|
||||
explicit_cause: Option<Option<ExplosionCause>>,
|
||||
direct_name: Option<Text>,
|
||||
held_item: Option<Text>,
|
||||
credit: bool,
|
||||
}
|
||||
|
||||
/// An entity in the blast's reach: how far (as a share of the blast's
|
||||
@@ -187,12 +202,10 @@ fn hurt_mob(
|
||||
hurtable: bool,
|
||||
cause: Option<horizon_client::mobs::Species>,
|
||||
(amount, source): (f32, horizon_client::mobs::Damage),
|
||||
) {
|
||||
) -> bool {
|
||||
let shielded =
|
||||
cause.is_some_and(|c| horizon_client::mobs::wither::shielded_from(m.species(), c));
|
||||
if hurtable && !shielded {
|
||||
m.damage(amount, source);
|
||||
}
|
||||
hurtable && !shielded && m.damage(amount, source)
|
||||
}
|
||||
|
||||
/// A blast's causing entity and its name (`Explosion.getIndirectSourceEntity`):
|
||||
@@ -228,8 +241,101 @@ pub(super) fn client_float() -> f32 {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `blast_cause` for the blast going off now.
|
||||
pub(super) fn explosion_entity_name(&self, id: u32, kind: &str) -> Text {
|
||||
self.persist_raw(id)
|
||||
.and_then(|r| r.get("CustomName"))
|
||||
.map_or_else(
|
||||
|| Text::translate(&format!("entity.{}", kind.replace(':', ".")), &[]),
|
||||
|name| {
|
||||
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(
|
||||
name,
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn explosion_cause_resolves(&self, cause: &ExplosionCause) -> bool {
|
||||
if !cause.player {
|
||||
return self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == cause.entity.0 && !m.removed);
|
||||
}
|
||||
let acting = self
|
||||
.lan_acting_entity
|
||||
.or_else(|| self.player.as_ref().map(|p| p.entity_id as u32));
|
||||
acting == Some(cause.entity.0)
|
||||
|| self.lan.as_ref().is_some_and(|host| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.any(|g| g.entity_id as u32 == cause.entity.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// An explicit DamageSource must not inherit an enclosing blast's cause.
|
||||
pub(super) fn with_explosion_cause<R>(
|
||||
&mut self,
|
||||
cause: Option<ExplosionCause>,
|
||||
direct_source: (u32, Text),
|
||||
run: impl FnOnce(&mut Self) -> R,
|
||||
) -> R {
|
||||
// The DamageSource retains the removed mob's display name. This is
|
||||
// deliberately not added to exploded_kinds: its EntityReference no
|
||||
// longer resolves for kill credit, unlike Horizon's early exploders.
|
||||
if let Some(cause) = &cause
|
||||
&& cause.species.is_some()
|
||||
&& !self.mobs.iter().any(|m| m.id == cause.entity.0)
|
||||
&& !self
|
||||
.combat
|
||||
.exploded
|
||||
.iter()
|
||||
.any(|(id, _)| *id == cause.entity.0)
|
||||
{
|
||||
self.combat.exploded.push(cause.entity.clone());
|
||||
}
|
||||
let source = self.combat.explosion_source.take();
|
||||
let owner = self.combat.explosion_owner.take();
|
||||
let player = self.combat.blast_owner.take();
|
||||
let guest = self.combat.blast_guest.take();
|
||||
let direct = self.combat.explosion_cause.take();
|
||||
self.combat.explosion_source = Some(direct_source.0);
|
||||
let outer_direct = self
|
||||
.combat
|
||||
.explicit_explosion_direct
|
||||
.replace(direct_source.1);
|
||||
if let Some(cause) = &cause {
|
||||
if cause.player {
|
||||
self.combat.blast_owner = Some(cause.entity.clone());
|
||||
self.combat.blast_guest = cause.guest;
|
||||
} else {
|
||||
self.combat.explosion_owner = Some(cause.entity.0);
|
||||
}
|
||||
}
|
||||
let outer = self.combat.explicit_explosion_cause.replace(cause);
|
||||
let result = run(self);
|
||||
self.combat.explicit_explosion_cause = outer;
|
||||
self.combat.explosion_source = source;
|
||||
self.combat.explosion_owner = owner;
|
||||
self.combat.blast_owner = player;
|
||||
self.combat.blast_guest = guest;
|
||||
self.combat.explosion_cause = direct;
|
||||
self.combat.explicit_explosion_direct = outer_direct;
|
||||
result
|
||||
}
|
||||
|
||||
/// The blast damage's cause. An explicit crystal/cart DamageSource may
|
||||
/// differ from the explosion's own indirect source.
|
||||
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
|
||||
if let Some(cause) = &self.combat.explicit_explosion_cause {
|
||||
return cause.as_ref().map(|c| {
|
||||
let name = (!c.player)
|
||||
.then(|| self.mob_display_name(c.entity.0))
|
||||
.flatten()
|
||||
.unwrap_or_else(|| c.entity.1.clone());
|
||||
(c.entity.0, name)
|
||||
});
|
||||
}
|
||||
// `Explosion.getIndirectSourceEntity`: a primed TNT's owner names the
|
||||
// blast before any mob does, which makes the damage type
|
||||
// `minecraft:player_explosion` (`server_explode` asks this).
|
||||
@@ -438,7 +544,7 @@ impl Game<'_> {
|
||||
attacker: Option<&(u32, Text)>,
|
||||
cause: Option<&horizon_client::advancements::EntityView>,
|
||||
) -> Option<[f64; 3]> {
|
||||
let ExplosionHookContext { center, blocks } = *context;
|
||||
let ExplosionHookContext { center, blocks, .. } = *context;
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
|
||||
// piece, so two Blast Protection IV pieces cancel it).
|
||||
@@ -491,10 +597,39 @@ impl Game<'_> {
|
||||
let flying = self.player.as_ref().is_some_and(|p| p.flying);
|
||||
let mut outcome = horizon_client::health::BlockOutcome::default();
|
||||
let mut pushed = None;
|
||||
let explicit = context.explicit_cause.as_ref().and_then(|c| c.as_ref());
|
||||
let living_attacker = explicit.is_none_or(|c| c.player || c.species.is_some());
|
||||
let living_non_player = context
|
||||
.explicit_cause
|
||||
.as_ref()
|
||||
.map_or(attacker.is_some(), |c| {
|
||||
c.as_ref().is_some_and(|c| !c.player && c.species.is_some())
|
||||
});
|
||||
// The death message names the blast's causing entity. It goes to this
|
||||
// player's tracker, the host's or a guest's.
|
||||
if let Some((id, name)) = attacker.filter(|_| !creative && !spectator && hurtable) {
|
||||
self.note_named_attacker(damage_type, *id, name.clone());
|
||||
if living_attacker && context.credit {
|
||||
self.note_player_attacker(
|
||||
damage_type,
|
||||
*id,
|
||||
name.clone(),
|
||||
context.held_item.clone(),
|
||||
);
|
||||
} else {
|
||||
self.note_uncredited_attacker(
|
||||
damage_type,
|
||||
*id,
|
||||
name.clone(),
|
||||
context.held_item.clone(),
|
||||
living_attacker,
|
||||
);
|
||||
}
|
||||
} else if !creative
|
||||
&& !spectator
|
||||
&& hurtable
|
||||
&& let Some(direct) = context.direct_name.clone()
|
||||
{
|
||||
self.note_direct_entity(damage_type, direct);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(id) = self.health_resources.damage.id(damage_type)
|
||||
@@ -509,7 +644,7 @@ impl Game<'_> {
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
attacker.is_some(),
|
||||
living_non_player,
|
||||
// The blast's causing entity.
|
||||
attacker.map_or(0, |(id, _)| *id as i32),
|
||||
)
|
||||
@@ -617,7 +752,33 @@ impl Game<'_> {
|
||||
// same for all of them (`onExplosionHit(this.source)`).
|
||||
let attacker = self.blast_attacker();
|
||||
let blast_source = self.combat.explosion_cause.clone();
|
||||
let context = ExplosionHookContext { center, blocks };
|
||||
let context = ExplosionHookContext {
|
||||
center,
|
||||
blocks,
|
||||
explicit_cause: self.combat.explicit_explosion_cause.clone(),
|
||||
direct_name: self.combat.explicit_explosion_direct.clone(),
|
||||
credit: self
|
||||
.combat
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.is_none_or(|cause| {
|
||||
cause
|
||||
.as_ref()
|
||||
.is_some_and(|cause| self.explosion_cause_resolves(cause))
|
||||
}),
|
||||
held_item: self
|
||||
.combat
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.and_then(|c| c.as_ref())
|
||||
.and_then(|c| {
|
||||
if c.player {
|
||||
self.player_main_hand_custom_name(c.guest)
|
||||
} else {
|
||||
self.mob_main_hand_name(c.entity.0)
|
||||
}
|
||||
}),
|
||||
};
|
||||
let own = self.explosion_hurt_player(
|
||||
&context,
|
||||
radius,
|
||||
@@ -655,10 +816,17 @@ impl Game<'_> {
|
||||
// Mobs. `WitherBoss.hurtServer`: a wither shrugs off an undead cause.
|
||||
let cause = self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.species());
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.and_then(|c| c.as_ref())
|
||||
.and_then(|c| c.species)
|
||||
.or_else(|| {
|
||||
self.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.species())
|
||||
});
|
||||
// `ServerExplosion.hurtEntities`: every entity in reach, on the walk. Each
|
||||
// answers `ignoreExplosion` itself (`Entity::ignores_explosion`); the rest
|
||||
// is the same for all, and only what a blast does to a kind differs. The
|
||||
@@ -667,7 +835,8 @@ impl Game<'_> {
|
||||
// (as `retain` does), and a destroyed boat or cart shifts its list, so
|
||||
// `gone` counts how far.
|
||||
let blast = Blast {
|
||||
vehicles_ignore: self.combat.blast_owner.is_none()
|
||||
vehicles_ignore: self.combat.explicit_explosion_cause.is_none()
|
||||
&& self.combat.blast_owner.is_none()
|
||||
&& self.blast_attacker().is_some()
|
||||
&& !self.ui.rules.bool("mob_griefing"),
|
||||
items_ignore: items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction),
|
||||
@@ -704,18 +873,53 @@ impl Game<'_> {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
// A projectile's owner (`getIndirectSourceEntity`),
|
||||
// else the exploding mob itself.
|
||||
attacker: attacker
|
||||
attacker: self
|
||||
.combat
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
.and_then(|c| c.as_ref())
|
||||
.filter(|c| c.player)
|
||||
.map(|_| Who::Player)
|
||||
.or_else(|| {
|
||||
attacker
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.filter(|_| {
|
||||
self.combat
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.is_none_or(|c| {
|
||||
c.as_ref().is_some_and(|c| c.species.is_some())
|
||||
})
|
||||
})
|
||||
.map(horizon_client::mobs::Who::Mob)
|
||||
}),
|
||||
from: Some(center),
|
||||
attacker_species: cause,
|
||||
};
|
||||
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`:
|
||||
// its base is 0 for every mob type, so iron golems and
|
||||
// ravagers are pushed.
|
||||
hurt_mob(m, hurtable, cause, (amount, source));
|
||||
let victim = m.id;
|
||||
let hurt = hurt_mob(m, hurtable, cause, (amount, source));
|
||||
if hurt {
|
||||
if source.attacker == Some(Who::Player) {
|
||||
let guest = self.combat.blast_guest;
|
||||
self.last_hurt_by_player.insert(victim, guest);
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
match guest {
|
||||
Some(conn) => {
|
||||
host.mob_players.insert(victim, conn);
|
||||
}
|
||||
None => {
|
||||
host.mob_players.remove(&victim);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.resolve_player_responsible(victim, source);
|
||||
}
|
||||
// Enderman.hurtServer teleports inside the hurt, before
|
||||
// the push (the mob order stays as it was).
|
||||
if let Err(e) = self.settle_hurt_teleports() {
|
||||
@@ -817,8 +1021,15 @@ impl Game<'_> {
|
||||
self.explosion_breaks_decorations(center, diameter, interaction);
|
||||
pushed
|
||||
}
|
||||
/// `ServerExplosion.getIndirectSourceEntity`: the causing living entity,
|
||||
/// including a TNT igniter or the player who broke an end crystal.
|
||||
fn blast_damage_caused_by_mob(&self) -> bool {
|
||||
self.combat.explicit_explosion_cause.as_ref().map_or_else(
|
||||
|| self.blast_attacker().is_some(),
|
||||
|cause| cause.as_ref().is_some_and(|c| c.species.is_some()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Decoration kills credit `DamageSource.getEntity`: the explicit blast
|
||||
/// cause, including a TNT igniter or the player who broke an end crystal.
|
||||
pub(super) fn explosion_kill_credit(&self) -> Option<super::commands::Target> {
|
||||
use super::commands::Target;
|
||||
// A primed TNT's owner (a player) names the blast before any mob does.
|
||||
@@ -834,10 +1045,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.crystal_blast_player()
|
||||
}
|
||||
/// `blast_spares_decorations` for the blast going off now: its causing
|
||||
/// mob (`blast_attacker`) is the source's entity.
|
||||
/// Decoration hurt checks use the damage source's cause (`instanceof Mob`),
|
||||
/// unlike vehicles, which use the explosion's indirect source.
|
||||
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
|
||||
let by_mob = self.blast_attacker().is_some();
|
||||
let by_mob = self.blast_damage_caused_by_mob();
|
||||
blast_spares_decorations(
|
||||
self.ui.rules.bool("mob_griefing"),
|
||||
interaction,
|
||||
@@ -865,7 +1076,7 @@ impl Game<'_> {
|
||||
interaction: BlockInteraction,
|
||||
) {
|
||||
// An armour stand is no `BlockAttachedEntity`: a source in water does not spare it.
|
||||
let by_mob = self.blast_attacker().is_some();
|
||||
let by_mob = self.blast_damage_caused_by_mob();
|
||||
let spares_stands = blast_spares_decorations(
|
||||
self.ui.rules.bool("mob_griefing"),
|
||||
interaction,
|
||||
@@ -961,12 +1172,38 @@ impl Game<'_> {
|
||||
fire: bool,
|
||||
source: ExplosionSource,
|
||||
) -> Result<()> {
|
||||
let damage_type = super::creatures::damage_type(
|
||||
horizon_client::mobs::DamageKind::Explosion,
|
||||
self.blast_attacker().is_some(),
|
||||
);
|
||||
self.server_explode_as_source(center, radius, interaction, fire, damage_type, None, source)
|
||||
self.server_explode_from_cause(center, radius, interaction, fire, source, None)
|
||||
}
|
||||
/// An explicit `DamageSource` is independent of the blast's indirect owner.
|
||||
/// `Some(None)` is explicitly ownerless; `None` keeps the ordinary source.
|
||||
pub(super) fn server_explode_from_cause(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
interaction: BlockInteraction,
|
||||
fire: bool,
|
||||
source: ExplosionSource,
|
||||
cause: Option<Option<ExplosionCause>>,
|
||||
) -> Result<()> {
|
||||
let blast = |g: &mut Self| {
|
||||
let damage_type = super::creatures::damage_type(
|
||||
horizon_client::mobs::DamageKind::Explosion,
|
||||
g.blast_attacker().is_some(),
|
||||
);
|
||||
g.server_explode_as_source(center, radius, interaction, fire, damage_type, None, source)
|
||||
};
|
||||
match cause {
|
||||
None => blast(self),
|
||||
Some(cause) => {
|
||||
let (id, kind) = source.entity.ok_or_else(|| {
|
||||
io::Error::other("an explicit explosion cause needs its direct entity")
|
||||
})?;
|
||||
let direct = (id, self.explosion_entity_name(id, kind));
|
||||
self.with_explosion_cause(cause, direct, blast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `server_explode` with the damage type the player takes and a block
|
||||
/// whose explosion resistance the damage calculator overrides.
|
||||
pub(super) fn server_explode_as(
|
||||
@@ -1139,6 +1376,15 @@ impl Game<'_> {
|
||||
if !is_tnt && name.starts_with("minecraft:infested_") {
|
||||
self.spawn_infestation(pos);
|
||||
}
|
||||
// Its dropExperience is whether the blast's indirect source is a
|
||||
// player (a primed TNT's owner, not a mob's): ores, sculk and spawners
|
||||
// sample their experience then, and pop it while blocks drop.
|
||||
if !is_tnt && self.combat.blast_owner_player {
|
||||
let xp = self.block_experience(&name);
|
||||
if xp > 0 && self.ui.rules.bool("block_drops") {
|
||||
self.spawn_orbs(pos.map(|v| f64::from(v) + 0.5), xp);
|
||||
}
|
||||
}
|
||||
if !is_tnt {
|
||||
let dynamic = self.pending.dynamic(pos);
|
||||
// LootContextParams.EXPLOSION_RADIUS under DESTROY_WITH_DECAY: the
|
||||
@@ -1186,10 +1432,18 @@ impl Game<'_> {
|
||||
let rot = r.next_double() * f64::from(std::f32::consts::TAU);
|
||||
let fuse = r.next_int_bound(20).unwrap_or(0) + 10;
|
||||
// TntBlock.wasExploded hands the new TNT the blast's indirect
|
||||
// source, so a chain reaction keeps the player who started it.
|
||||
let (owner, guest) = match &self.combat.blast_owner {
|
||||
Some(_) => (Some(Who::Player), self.combat.blast_guest),
|
||||
None => (None, None),
|
||||
// source, so a chain reaction keeps a primed TNT's owner, player or mob.
|
||||
let (owner, guest) = match &self.combat.explicit_explosion_cause {
|
||||
// A crystal/cart's explicit DamageSource does not change
|
||||
// Explosion.getIndirectSourceEntity, which is null for them.
|
||||
Some(_) => (None, None),
|
||||
None => match &self.combat.blast_owner {
|
||||
Some(_) if self.combat.blast_owner_player => {
|
||||
(Some(Who::Player), self.combat.blast_guest)
|
||||
}
|
||||
Some((id, _)) => (Some(Who::Mob(*id)), None),
|
||||
None => (None, None),
|
||||
},
|
||||
};
|
||||
self.add_primed_tnt(
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
@@ -1795,6 +2049,115 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod explicit_source_tests {
|
||||
use super::*;
|
||||
use crate::game::native_explosions::NativeBlast;
|
||||
use horizon_client::{mobs::Mob, motion::PlayerMotion};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources"]
|
||||
fn explicit_sources_keep_names_without_inventing_kill_credit() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.options.dedicated = None;
|
||||
game.creative = false;
|
||||
game.ui.difficulty = 2;
|
||||
game.build_registries();
|
||||
for mode in 0..5 {
|
||||
let nested = mode == 4;
|
||||
game.combat = Default::default();
|
||||
game.mobs.clear();
|
||||
game.orbs.clear();
|
||||
game.dropped.clear();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, 8.5)).unwrap());
|
||||
let kind = game.res.mob_types().get("minecraft:husk").unwrap().clone();
|
||||
let species = kind.species;
|
||||
if mode == 2 || nested {
|
||||
game.mobs
|
||||
.push(Mob::new(43, kind, Vec3::new(40.0, 64.0, 40.0), 0.0));
|
||||
}
|
||||
// An enclosing blast must not leak its owner into an explicit source.
|
||||
game.combat.explosion_owner = Some(99);
|
||||
game.combat.exploded.push((99, Text::literal("Outer")));
|
||||
let cause = (mode != 0).then(|| ExplosionCause {
|
||||
entity: (43, Text::literal("Igniter")),
|
||||
player: false,
|
||||
guest: None,
|
||||
species: (mode != 1).then_some(species),
|
||||
});
|
||||
if nested {
|
||||
game.options.dedicated = Some(Vec::new());
|
||||
game.combat.explicit_explosion_cause = Some(cause);
|
||||
game.combat.explicit_explosion_direct = Some(Text::literal("Outer"));
|
||||
game.combat.blast_owner = Some((99, Text::literal("Outer")));
|
||||
game.combat.blast_guest = Some(17);
|
||||
let center = [8.5, 64.0, 8.5];
|
||||
assert!(game.native_explode(
|
||||
"minecraft:overworld",
|
||||
center,
|
||||
2.0,
|
||||
false,
|
||||
NativeBlast::Keep
|
||||
));
|
||||
assert!(matches!(
|
||||
game.combat.explicit_explosion_cause,
|
||||
Some(Some(_))
|
||||
));
|
||||
assert!(game.combat.explicit_explosion_direct.is_some());
|
||||
assert_eq!(
|
||||
game.combat.blast_owner.as_ref().map(|owner| owner.0),
|
||||
Some(99)
|
||||
);
|
||||
assert_eq!(game.combat.blast_guest, Some(17));
|
||||
} else {
|
||||
game.server_explode_from_cause(
|
||||
[8.5, 64.0, 8.5],
|
||||
2.0,
|
||||
BlockInteraction::Keep,
|
||||
false,
|
||||
ExplosionSource::default()
|
||||
.with_entity(7, "minecraft:end_crystal")
|
||||
.with_silence(true),
|
||||
Some(cause),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let events = std::mem::take(&mut game.player.as_mut().unwrap().events);
|
||||
game.record_combat(&events);
|
||||
assert_eq!(
|
||||
game.combat
|
||||
.last_blow()
|
||||
.map(|(kind, id)| (kind.to_owned(), id)),
|
||||
Some((
|
||||
if mode == 0 || nested {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
"minecraft:player_explosion"
|
||||
}
|
||||
.into(),
|
||||
if mode == 0 || nested { 0 } else { 43 }
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
game.combat.kill_credit(game.world.game_time),
|
||||
(mode == 2).then_some(43)
|
||||
);
|
||||
if mode == 1 || mode == 3 || nested {
|
||||
let death = game
|
||||
.combat_death_message_for(Text::literal("Victim"))
|
||||
.component();
|
||||
if nested {
|
||||
assert_eq!(death["with"].as_array().unwrap().len(), 1);
|
||||
} else {
|
||||
assert_eq!(death["with"][1]["text"].as_str(), Some("Igniter"));
|
||||
}
|
||||
}
|
||||
assert_eq!(game.combat.explosion_owner, Some(99));
|
||||
assert_eq!(game.combat.explicit_explosion_cause.is_some(), nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shulker_lock_drop_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
|
||||
@@ -201,6 +201,15 @@ impl Rocket {
|
||||
}
|
||||
self.life += 1;
|
||||
}
|
||||
/// The end of `Projectile.shoot`: yaw and pitch are set straight from the
|
||||
/// shot's motion (the old rotation follows, as `rotation` is the old one
|
||||
/// on the next tick).
|
||||
fn turn_to_shot(&mut self) {
|
||||
let v = self.velocity;
|
||||
let horizontal = (v.x * v.x + v.z * v.z).sqrt();
|
||||
let atan2 = horizon_core::geometry::trig::atan2_degrees;
|
||||
self.rotation = [atan2(v.x, v.z), atan2(v.y, horizontal)];
|
||||
}
|
||||
/// `Projectile.updateRotation`: a fifth of the way from `old` (yaw,
|
||||
/// pitch) toward its motion's direction, the short way round.
|
||||
fn update_rotation(&mut self, old: [f32; 2]) {
|
||||
@@ -485,6 +494,7 @@ impl Game<'_> {
|
||||
let mut own = random_source::entity();
|
||||
let mut rocket = self.new_rocket(at, stack, false, &mut own);
|
||||
rocket.velocity = shoot(&mut own);
|
||||
rocket.turn_to_shot();
|
||||
rocket.angled = true;
|
||||
rocket.owner = owner;
|
||||
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
|
||||
@@ -1045,6 +1055,20 @@ mod events_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// `Projectile.shoot` turns a shot rocket to its motion at once.
|
||||
#[test]
|
||||
fn a_shot_rocket_starts_turned_to_its_motion() {
|
||||
use horizon_core::geometry::Vec3;
|
||||
let mut rocket = super::Rocket::remote(1, Vec3::ZERO, Vec3::new(0.0, 0.5, 0.0));
|
||||
rocket.turn_to_shot();
|
||||
assert!((rocket.rotation[1] - 90.0).abs() < 1e-3);
|
||||
let mut rocket = super::Rocket::remote(1, Vec3::ZERO, Vec3::new(1.0, 0.0, 1.0));
|
||||
rocket.turn_to_shot();
|
||||
assert!((rocket.rotation[0] - 45.0).abs() < 1e-4);
|
||||
assert!(rocket.rotation[1].abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// A rocket draws its UUID's two longs first, then the motion's two triangles
|
||||
/// (four doubles), then `nextInt(6)` and `nextInt(7)` for the lifetime, all from
|
||||
/// the one random. The expected values come from `java.util.Random` replayed
|
||||
|
||||
@@ -24,6 +24,9 @@ use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::lan_actions::GuestPlay;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fishing_air_tests.rs"]
|
||||
mod air_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "fishing_block_hit_tests.rs"]
|
||||
mod block_hit_tests;
|
||||
@@ -714,8 +717,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// getOpenWaterTypeForBlock: 0 above water, 1 inside water, 2 invalid.
|
||||
fn open_water_type(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
let f = self.res.facts().get(self.world.block(x, y, z));
|
||||
if self.world.is_air(self.world.block(x, y, z)) || &*f.name == "minecraft:lily_pad" {
|
||||
let state = self.world.block(x, y, z);
|
||||
let f = self.res.facts().get(state);
|
||||
// `!state.isAir()`: cave_air and void_air are air too.
|
||||
if self.res.facts().is_air(state) || &*f.name == "minecraft:lily_pad" {
|
||||
return 0;
|
||||
}
|
||||
if f.fluid == Fluid::Water
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! `FishingHook.getOpenWaterTypeForBlock` tests `!state.isAir()`, and `isAir()` covers
|
||||
//! cave_air and void_air: a hook over a source under a cave's air is open water, so its
|
||||
//! catch can be treasure. (A stone block is not air, so it is not open water either.)
|
||||
use super::{FISHING_TABLE, fishing_loot};
|
||||
use horizon_world::loot::LootStack;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn cave_air_above_a_water_source_is_open_water_and_catches_treasure() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let (air, cave_air, void_air, water, stone) = {
|
||||
let registry = &game.pack.assets.registry;
|
||||
let id = |name: &str| registry.default_state(name).unwrap();
|
||||
(
|
||||
id("minecraft:air"),
|
||||
id("minecraft:cave_air"),
|
||||
id("minecraft:void_air"),
|
||||
id("minecraft:water"),
|
||||
id("minecraft:stone"),
|
||||
)
|
||||
};
|
||||
// The three air blocks read alike: ABOVE_WATER (0), never INVALID (2).
|
||||
for (name, state) in [("air", air), ("cave_air", cave_air), ("void_air", void_air)] {
|
||||
game.world.set(5, 63, 5, state).unwrap();
|
||||
assert_eq!(game.open_water_type(5, 63, 5), 0, "{name} is air");
|
||||
}
|
||||
// calculateOpenWater's 5x4x5 area around the hook's block: a source at -1 and the
|
||||
// cave's air at 0..2, as in a mineshaft room filled with water.
|
||||
for x in 3..=7 {
|
||||
for z in 3..=7 {
|
||||
game.world.set(x, 60, z, water).unwrap();
|
||||
for y in 61..=63 {
|
||||
game.world.set(x, y, z, cave_air).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
game.calculate_open_water([5, 61, 5]),
|
||||
"cave_air above a source is open water"
|
||||
);
|
||||
// One block that is not air: the area is not open water.
|
||||
game.world.set(5, 62, 5, stone).unwrap();
|
||||
assert!(!game.calculate_open_water([5, 61, 5]));
|
||||
game.world.set(5, 62, 5, cave_air).unwrap();
|
||||
|
||||
// The flag the hook's retrieval reads: `gameplay/fishing`'s treasure entry is
|
||||
// conditioned on `in_open_water`, so the cave's air decides whether treasure comes.
|
||||
let loot =
|
||||
horizon_client::structure_blocks::loot_data(&game.options.resources, &game.pack_files())
|
||||
.expect("the fishing loot table");
|
||||
// `gameplay/fishing/treasure`'s items (26.3's table).
|
||||
let treasure = [
|
||||
"minecraft:name_tag",
|
||||
"minecraft:saddle",
|
||||
"minecraft:bow",
|
||||
"minecraft:fishing_rod",
|
||||
"minecraft:book",
|
||||
"minecraft:nautilus_shell",
|
||||
];
|
||||
let catches = |open_water: bool| -> Vec<LootStack> {
|
||||
(0..400)
|
||||
.flat_map(|seed| {
|
||||
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
|
||||
let mut level_random = horizon_core::random::LegacyRandom::new(seed);
|
||||
fishing_loot(
|
||||
&loot,
|
||||
FISHING_TABLE,
|
||||
&mut sequences,
|
||||
&mut level_random,
|
||||
seed,
|
||||
0.0,
|
||||
None,
|
||||
open_water,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let treasure_catches = |items: &[LootStack]| {
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| treasure.contains(&item.item.as_str()))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(
|
||||
treasure_catches(&catches(false)),
|
||||
0,
|
||||
"not open water: no treasure"
|
||||
);
|
||||
assert!(
|
||||
treasure_catches(&catches(true)) > 0,
|
||||
"open water can catch treasure"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! A projectile that strikes an item frame hurts it (#11383): `ItemFrame.hurtServer`
|
||||
//! gives the item up, an empty frame breaks, and a fixed one refuses the hit.
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn game() -> 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,
|
||||
true,
|
||||
&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.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();
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"fill 0 63 0 15 63 15 stone",
|
||||
"fill 4 64 0 4 66 15 stone",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
game
|
||||
}
|
||||
|
||||
/// An arrow from `(0.5, 64.5, z)` flying east into the wall at x = 4.
|
||||
fn shoot(game: &mut Game<'_>, z: f64) {
|
||||
assert!(game.run_command(&format!(
|
||||
"summon minecraft:arrow 0.5 64.5 {z} {{Motion:[1.0d,0.0d,0.0d]}}"
|
||||
)));
|
||||
for _ in 0..10 {
|
||||
game.tick_projectiles().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn dropped(game: &Game<'_>, item: &str) -> usize {
|
||||
game.dropped
|
||||
.iter()
|
||||
.filter(|d| &*d.stack.item == item)
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_arrow_knocks_the_item_out_then_breaks_the_frame() {
|
||||
let mut game = game();
|
||||
// On the wall's west face (Facing 4: west).
|
||||
assert!(game.run_command(
|
||||
"summon minecraft:item_frame 3.5 64.5 6.5 {Facing:4b,Item:{id:\"minecraft:diamond\",count:1}}"
|
||||
));
|
||||
assert_eq!(game.item_frames.len(), 1);
|
||||
shoot(&mut game, 6.5);
|
||||
assert_eq!(game.item_frames.len(), 1, "the first hit leaves the frame");
|
||||
assert!(game.item_frames[0].item.is_none(), "the item came out");
|
||||
assert_eq!(dropped(&game, "minecraft:diamond"), 1);
|
||||
// The empty frame breaks and drops itself.
|
||||
shoot(&mut game, 6.5);
|
||||
assert!(game.item_frames.is_empty());
|
||||
assert_eq!(dropped(&game, "minecraft:item_frame"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_fixed_frame_refuses_the_arrow() {
|
||||
let mut game = game();
|
||||
assert!(game.run_command(
|
||||
"summon minecraft:item_frame 3.5 64.5 9.5 {Facing:4b,Fixed:1b,Item:{id:\"minecraft:diamond\",count:1}}"
|
||||
));
|
||||
shoot(&mut game, 9.5);
|
||||
assert_eq!(game.item_frames.len(), 1);
|
||||
assert!(
|
||||
game.item_frames[0].item.is_some(),
|
||||
"a fixed frame keeps its item"
|
||||
);
|
||||
assert_eq!(dropped(&game, "minecraft:diamond"), 0);
|
||||
// The frame took the arrow's hit and refused it, so the arrow turned back
|
||||
// (`AbstractArrow.onHitEntity`'s deflection) instead of reaching the wall.
|
||||
assert!(
|
||||
game.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.all(|p| p.position.x < 3.9),
|
||||
"the arrow went through the frame"
|
||||
);
|
||||
}
|
||||
@@ -349,12 +349,45 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
self.hit_frame(i, true, self.creative);
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.hurtServer` from a projectile (`AbstractArrow.onHitEntity`,
|
||||
/// `Snowball`, `ThrownEgg`, `ThrownEnderpearl`, `SmallFireball`...): the
|
||||
/// damage source's entity is the projectile's owner. A fixed frame only
|
||||
/// gives way to a creative player's shot (`canHurtWhenFixed`), and then
|
||||
/// breaks whole (`BlockAttachedEntity.hurtServer`). `creative`: that player
|
||||
/// has infinite materials. False when the frame refused the hit.
|
||||
pub(super) fn projectile_hurts_frame(
|
||||
&mut self,
|
||||
id: u32,
|
||||
by_player: bool,
|
||||
creative: bool,
|
||||
) -> bool {
|
||||
let Some(i) = self.item_frames.iter().position(|f| f.id == id) else {
|
||||
return false;
|
||||
};
|
||||
if self.item_frames[i].fixed {
|
||||
if !creative {
|
||||
return false;
|
||||
}
|
||||
self.item_frames[i].item = None;
|
||||
}
|
||||
self.hit_frame(i, by_player, creative);
|
||||
true
|
||||
}
|
||||
/// The non-fixed part of `ItemFrame.hurtServer`: a filled frame gives its
|
||||
/// item up (`shouldDamageDropItem`), an empty one breaks
|
||||
/// (`BlockAttachedEntity.hurtServer`). `by_player`: the damage source's
|
||||
/// entity is the acting player, `creative` when they have infinite
|
||||
/// materials (no drops).
|
||||
fn hit_frame(&mut self, i: usize, by_player: bool, creative: bool) {
|
||||
let with_frame = self.item_frames[i].item.is_none();
|
||||
let entity_id = self.item_frames[i].id;
|
||||
if with_frame {
|
||||
let (kind, pos) = (self.item_frames[i].kind(), self.item_frames[i].pos);
|
||||
if self.hanging_break_cancelled(kind, pos, true) {
|
||||
return true;
|
||||
if self.hanging_break_cancelled(kind, pos, by_player) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let old_item = self.item_frames[i]
|
||||
@@ -380,13 +413,14 @@ impl Game<'_> {
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
self.drop_frame_items(&mut f, with_frame, true);
|
||||
self.drop_frame_items(&mut f, with_frame, by_player, creative);
|
||||
if !with_frame {
|
||||
self.item_frames[i].random = f.random.clone();
|
||||
}
|
||||
// BLOCK_CHANGE by the attacker after the drops: ItemFrame.hurtServer
|
||||
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
let who = by_player.then_some(horizon_client::mobs::Who::Player);
|
||||
let ctx = self.who_context(who, None);
|
||||
self.post_game_event("minecraft:block_change", at, ctx);
|
||||
if !with_frame {
|
||||
// ItemFrame.hurtServer plays this after the event.
|
||||
@@ -394,7 +428,9 @@ impl Game<'_> {
|
||||
.play(&sound(&f, "remove_item"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
if by_player
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_item_frame_change(
|
||||
player,
|
||||
&name,
|
||||
@@ -404,7 +440,6 @@ impl Game<'_> {
|
||||
rotation.rem_euclid(8) as u8,
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.setItem(stack, true)`: keeps one item, plays the add sound
|
||||
/// for a non-empty stack and updates the comparators.
|
||||
@@ -428,14 +463,20 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
|
||||
}
|
||||
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
|
||||
fn drop_frame_items(&mut self, f: &mut ItemFrame, with_frame: bool, by_player: bool) {
|
||||
fn drop_frame_items(
|
||||
&mut self,
|
||||
f: &mut ItemFrame,
|
||||
with_frame: bool,
|
||||
by_player: bool,
|
||||
creative: bool,
|
||||
) {
|
||||
// The framed map forgets the frame, except when the drops are off and
|
||||
// something caused the break (`removeFramedMap` runs only for a null
|
||||
// `causedBy` then).
|
||||
if self.ui.rules.bool("entity_drops") || !by_player {
|
||||
self.remove_framed_map(f);
|
||||
}
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && creative) {
|
||||
return;
|
||||
}
|
||||
let s = f.step().map(f64::from);
|
||||
@@ -498,7 +539,7 @@ impl Game<'_> {
|
||||
self.entity_killed_event_by(center(&f), self.explosion_kill_credit());
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
self.drop_frame_items(&mut f, true, false, false);
|
||||
// ItemFrame.dropItem(level, causedBy): BLOCK_CHANGE after the drops.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
@@ -532,7 +573,7 @@ impl Game<'_> {
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
self.drop_frame_items(&mut f, true, false, false);
|
||||
// ItemFrame.dropItem(level, null): sound, drops, then event.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Production gateway counters and direct phases over a silent, empty Game.
|
||||
use crate::game::{
|
||||
boats::Boat,
|
||||
entity::Entity,
|
||||
minecarts::{CartKind, Minecart},
|
||||
};
|
||||
use horizon_client::{
|
||||
end_gateway::Traveller,
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs explicit HORIZON_TEST_RESOURCES and HORIZON_PORTAL_REFERENCE"]
|
||||
fn gateway_counters_match_actual_vanilla_methods() {
|
||||
let reference: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(std::env::var_os("HORIZON_PORTAL_REFERENCE").unwrap()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let boat = Boat::new(1, "oak".into(), Vec3::ZERO, 0.0);
|
||||
let cart = Minecart::new(2, CartKind::Rideable, Vec3::ZERO);
|
||||
let arrow = Projectile::new(
|
||||
ProjectileKind::Arrow {
|
||||
base_damage: 2.0,
|
||||
critical: false,
|
||||
effect: None,
|
||||
},
|
||||
Vec3::ZERO,
|
||||
None,
|
||||
);
|
||||
let delays = [
|
||||
boat.dimension_changing_delay(),
|
||||
cart.dimension_changing_delay(),
|
||||
arrow.dimension_changing_delay(),
|
||||
horizon_client::nether_portal::ENTITY_DELAY,
|
||||
];
|
||||
assert_eq!(serde_json::json!(delays), reference["delays"]);
|
||||
let mut travellers: [Traveller; 4] = std::array::from_fn(|_| Traveller::default());
|
||||
let entry = BlockPos::new(0, 100, 0);
|
||||
for (traveller, delay) in travellers.iter_mut().zip(delays) {
|
||||
traveller.enter_with_delay(entry, delay);
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), Some(entry));
|
||||
assert_eq!(traveller.cooldown(), delay);
|
||||
traveller.set_cooldown(5);
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
for (step, ticks) in [1, 3, 1, 1, 8].into_iter().enumerate() {
|
||||
for _ in 0..ticks {
|
||||
for (i, (traveller, delay)) in travellers.iter_mut().zip(delays).enumerate() {
|
||||
if step < 2 && i == 2 {
|
||||
traveller.enter_with_delay(entry, delay);
|
||||
}
|
||||
traveller.travel_with_delay(true, delay);
|
||||
if step < 2 && i != 2 {
|
||||
traveller.enter_with_delay(entry, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.push(
|
||||
travellers
|
||||
.iter()
|
||||
.map(Traveller::cooldown)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
let phases = game_gateway_phases();
|
||||
let report = serde_json::json!({"contact":rows,"phases":phases});
|
||||
assert_eq!(report["contact"], reference["contact"]);
|
||||
if let Some(path) = std::env::var_os("HORIZON_PORTAL_REPORT") {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the real dispatch with cooldown-only states, so no teleport destination is resolved.
|
||||
fn game_gateway_phases() -> [[i32; 2]; 4] {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
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.headless = true;
|
||||
options.game_dir = Some(std::env::current_dir().unwrap());
|
||||
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.options.dedicated = Some(Vec::new());
|
||||
game.world = horizon_client::world::World::new(game.res.facts().air, 1);
|
||||
for z in -2..=2 {
|
||||
for x in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.dim = crate::game::Dim::End;
|
||||
let mut ids = Vec::new();
|
||||
for (index, kind) in ["minecraft:oak_boat", "minecraft:ender_pearl"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let uuid = [11318, 20, 0, index as i32];
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String(kind.into())),
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(4.5f64.to_bits()),
|
||||
Tag::Double(210.0f64.to_bits()),
|
||||
Tag::Double(4.5f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
game.load_listed(kind, Some(uuid), None, tag).unwrap();
|
||||
ids.push(game.next_entity);
|
||||
}
|
||||
assert_eq!(game.boats.len(), 1);
|
||||
assert_eq!(game.creatures.projectiles.len(), 1);
|
||||
assert!(matches!(
|
||||
game.creatures.projectiles[0].kind,
|
||||
ProjectileKind::EnderPearl
|
||||
));
|
||||
game.tickets.update(Some(crate::ticket_levels::Sources {
|
||||
forced: vec![(0, 0)],
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(game.entity_ticking_area()(4, 4));
|
||||
for &id in &ids {
|
||||
let mut state = Traveller::default();
|
||||
state.set_cooldown(5);
|
||||
game.end.gateways.insert(id, state);
|
||||
}
|
||||
let counters = |game: &Game| {
|
||||
[
|
||||
game.end.gateways[&ids[0]].cooldown(),
|
||||
game.end.gateways[&ids[1]].cooldown(),
|
||||
]
|
||||
};
|
||||
let mut rows = [[0; 2]; 4];
|
||||
for tick in 0..2 {
|
||||
game.handle_end_gateway_portals();
|
||||
rows[tick * 2] = counters(&game);
|
||||
assert_eq!(rows[tick * 2], [4 - tick as i32, 5 - tick as i32]);
|
||||
game.handle_end_gateway_portals_phase(true);
|
||||
rows[tick * 2 + 1] = counters(&game);
|
||||
assert_eq!(rows[tick * 2 + 1], [4 - tick as i32, 4 - tick as i32]);
|
||||
}
|
||||
assert!(game.end.arrivals.is_empty());
|
||||
assert_eq!(game.creatures.projectiles.len(), 1);
|
||||
rows
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_items::mining::{self, DestroySpeedModifiers};
|
||||
|
||||
/// The point a `ServerboundInteractPacket` carries: `EntityHitResult.getLocation()
|
||||
/// - entity.position()`, measured from the entity's feet, as
|
||||
@@ -430,20 +431,29 @@ impl Game<'_> {
|
||||
return 0.0;
|
||||
}
|
||||
let held = self.held();
|
||||
let mut speed = self.items().mining_speed(held, &facts.name);
|
||||
let tool_speed = self.items().mining_speed(held, &facts.name);
|
||||
let hardness = facts.hardness;
|
||||
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
|
||||
speed = self.modify_destroy_speed(speed);
|
||||
speed *= block_break_speed;
|
||||
if self.mining_eyes_in_water() {
|
||||
speed *= self.submerged_mining_speed();
|
||||
}
|
||||
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
|
||||
speed /= 5.0;
|
||||
}
|
||||
let harvest =
|
||||
self.items()
|
||||
.has_correct_tool_for_drops(held, facts.requires_tool, &facts.name);
|
||||
let player = DestroySpeedModifiers {
|
||||
// Vanilla reads the attribute (recomputing a dirty one) only then.
|
||||
mining_efficiency: if tool_speed > 1.0 {
|
||||
self.mining_efficiency()
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
effects: self.potions.effects.mining_speed_factor(),
|
||||
block_break_speed,
|
||||
submerged_mining_speed: self
|
||||
.mining_eyes_in_water()
|
||||
.then(|| self.submerged_mining_speed()),
|
||||
on_ground: self.player.as_ref().is_none_or(|p| p.ground.on_ground),
|
||||
};
|
||||
let mut speed = mining::destroy_speed(tool_speed, &player);
|
||||
// NeoForge's PlayerEvent.BreakSpeed, where Player.getDestroySpeed ends.
|
||||
self.native_break_speed(pos, state, speed, &mut speed);
|
||||
speed / hardness / if harvest { 30.0 } else { 100.0 }
|
||||
mining::destroy_progress(hardness, harvest, speed)
|
||||
}
|
||||
/// `EntityFluidInteraction`: compare the eyes with the camera fluid surface.
|
||||
fn mining_eyes_in_water(&self) -> bool {
|
||||
|
||||
@@ -147,7 +147,7 @@ impl Game<'_> {
|
||||
// ItemStack.isDamageableItem: component removal and
|
||||
// unbreakable are checked before enchantment RNG or hooks.
|
||||
if stack.count == 0
|
||||
|| !info.equip_damage_on_hurt
|
||||
|| !horizon_client::item_sync::damage_on_hurt(&stack, info.equip_damage_on_hurt)
|
||||
|| component("minecraft:unbreakable").is_some()
|
||||
|| component("!minecraft:max_damage").is_some()
|
||||
|| component("!minecraft:damage").is_some()
|
||||
@@ -242,10 +242,8 @@ impl Game<'_> {
|
||||
{
|
||||
stack.damage += amount;
|
||||
if stack.damage >= max {
|
||||
let broken = stack.item.clone();
|
||||
let gone = stack.clone();
|
||||
*cell = None;
|
||||
self.award_stat("broken", &broken, 1);
|
||||
// LivingEntity.onEquippedItemBroken: the sound and the crumbs.
|
||||
self.player_equipment_broke(break_slot, &gone);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ impl Game<'_> {
|
||||
let mut cells = horizon_client::entities::ItemCells::new(&items);
|
||||
// A LAN or server guest only shows the host's items.
|
||||
let merging = self.remote.is_none();
|
||||
// `Entity.checkBelowWorld`: below `Level.getMinY() - 64`.
|
||||
let void = f64::from(self.level_min_y() - 64);
|
||||
let mut obstacles =
|
||||
horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
let mut nearby = Vec::new();
|
||||
@@ -144,7 +146,7 @@ impl Game<'_> {
|
||||
2
|
||||
};
|
||||
if merging
|
||||
&& (alive || item.age == 6000 && item.position.y > -128.0)
|
||||
&& (alive || item.age == 6000 && item.position.y >= void)
|
||||
&& item.ticks.is_multiple_of(rate)
|
||||
{
|
||||
let max_stack = |stack: &ItemStack| self.stack_max_size(stack).max(1);
|
||||
@@ -157,8 +159,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let item = &mut items[i];
|
||||
let mut alive = alive || (item.age < 6000 && item.position.y > -128.0);
|
||||
if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 {
|
||||
let mut alive = alive || (item.age < 6000 && item.position.y >= void);
|
||||
if merging && !removed[i] && item.age >= 6000 && item.position.y >= void {
|
||||
let mut extra_life = 0;
|
||||
self.native_item_expire(item.id, &item.stack, &mut extra_life);
|
||||
if extra_life > 0 {
|
||||
@@ -793,6 +795,20 @@ impl Game<'_> {
|
||||
self.player_drop(thrown);
|
||||
}
|
||||
|
||||
/// `ServerPlayer.drop(boolean)` for the guest `conn` (`gi` in the hub): the
|
||||
/// drop key's throw, then `setRemoteSlot` on the selected slot so a pickup
|
||||
/// that refills the stack is sent to the client that predicted the drop.
|
||||
pub(super) fn guest_drop_held(
|
||||
&mut self,
|
||||
host: &mut super::lan::LanHost,
|
||||
gi: usize,
|
||||
conn: u32,
|
||||
all: bool,
|
||||
) {
|
||||
self.as_guest(host, conn, |g| g.drop_held(all));
|
||||
host.hub.guests[gi].told_selected();
|
||||
}
|
||||
|
||||
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
|
||||
/// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the
|
||||
/// drop key) names the player as the thrower; `drop(stack, false)`, for
|
||||
@@ -1032,3 +1048,7 @@ mod escape_tests {
|
||||
assert_eq!(game.world.random.next_float(), level.next_float());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "item_void_tests.rs"]
|
||||
mod item_void_tests;
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `Entity.checkBelowWorld`: a dropped item is discarded below its level's
|
||||
//! floor minus 64, so the Nether and the End lose one at y = -64 while the
|
||||
//! Overworld keeps it down to y = -128.
|
||||
use super::super::Dim;
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn items_fall_to_each_dimensions_own_void_floor() {
|
||||
let mut game = server_game();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 70.0, 8.5)).unwrap());
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.entity_ticking_area()(8, 8));
|
||||
let keys: HashSet<(i32, i32)> = [(0, 0)].into();
|
||||
for (dim, floor) in [
|
||||
(Dim::Overworld, -128.0),
|
||||
(Dim::Nether, -64.0),
|
||||
(Dim::End, -64.0),
|
||||
] {
|
||||
game.dim = dim;
|
||||
game.world.shape = dim.shape();
|
||||
for (y, survives) in [(floor + 0.5, true), (floor - 0.5, false)] {
|
||||
game.dropped.clear();
|
||||
game.spawn_item(
|
||||
ItemStack::new("minecraft:stone", 1),
|
||||
[8.5, y, 8.5],
|
||||
[0.0, 0.0, 0.0],
|
||||
0,
|
||||
);
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
game.tick_items();
|
||||
assert_eq!(
|
||||
!game.dropped.is_empty(),
|
||||
survives,
|
||||
"{dim:?}: an item at y {y}, against the floor {floor}"
|
||||
);
|
||||
let mut saved = HashMap::new();
|
||||
game.save_every_entity(&keys, &mut saved);
|
||||
assert_eq!(
|
||||
saved.values().map(Vec::len).sum::<usize>() > 0,
|
||||
survives,
|
||||
"{dim:?}: an item at y {y} in the save"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_set_jigsaw_block(&mut self, p: &play::SetJigsawBlock) {
|
||||
pub(super) fn apply_set_jigsaw_block(&mut self, p: &play::SetJigsawBlock) {
|
||||
let pos = (p.pos[0], p.pos[1], p.pos[2]);
|
||||
let Some(b) = self.jigsaw_block_mut(pos) else {
|
||||
return;
|
||||
|
||||
@@ -112,6 +112,22 @@ impl Editor for JigsawEditor {
|
||||
&mut self.edits
|
||||
}
|
||||
|
||||
/// `JigsawBlockEditScreen` gives its two priority boxes their explanation;
|
||||
/// the other boxes have none.
|
||||
fn edit_tooltip(&self, edit: usize) -> Option<Text> {
|
||||
match edit {
|
||||
SELECTION_PRIORITY => Some(Text::translate(
|
||||
"jigsaw_block.selection_priority.tooltip",
|
||||
&[],
|
||||
)),
|
||||
PLACEMENT_PRIORITY => Some(Text::translate(
|
||||
"jigsaw_block.placement_priority.tooltip",
|
||||
&[],
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn items(&self, _entity: &BlockEntity, w: f32, _h: f32) -> Vec<Item> {
|
||||
let mid = (w / 2.0).floor();
|
||||
let tr = |key: &str| Text::translate(key, &[]);
|
||||
@@ -329,4 +345,77 @@ mod tests {
|
||||
e.press(&mut entity, (0, 0, 0), JOINT, false, false);
|
||||
assert_eq!(e.joint, JointType::Aligned);
|
||||
}
|
||||
|
||||
/// `JigsawBlockEditScreen` sets a tooltip on the two priority boxes and
|
||||
/// no other.
|
||||
#[test]
|
||||
fn the_priority_boxes_explain_themselves() {
|
||||
let e = editor();
|
||||
assert_eq!(
|
||||
e.edit_tooltip(SELECTION_PRIORITY)
|
||||
.unwrap()
|
||||
.translation_key(),
|
||||
Some("jigsaw_block.selection_priority.tooltip")
|
||||
);
|
||||
assert_eq!(
|
||||
e.edit_tooltip(PLACEMENT_PRIORITY)
|
||||
.unwrap()
|
||||
.translation_key(),
|
||||
Some("jigsaw_block.placement_priority.tooltip")
|
||||
);
|
||||
for edit in [POOL, NAME, TARGET, FINAL_STATE] {
|
||||
assert!(e.edit_tooltip(edit).is_none(), "box {edit}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_fields_keep_their_labels_values_and_packet_fields() {
|
||||
let block = JigsawBlock {
|
||||
selection_priority: 17,
|
||||
placement_priority: -5,
|
||||
..JigsawBlock::default()
|
||||
};
|
||||
let mut e = JigsawEditor::new(&block, false);
|
||||
let entity = BlockEntity::JigsawBlock(Box::new(block));
|
||||
let items = e.items(&entity, 320.0, 240.0);
|
||||
for (x, label, tip, value, changed) in [
|
||||
(
|
||||
7.0,
|
||||
"jigsaw_block.selection_priority",
|
||||
"jigsaw_block.selection_priority.tooltip",
|
||||
"17",
|
||||
"8",
|
||||
),
|
||||
(
|
||||
110.0,
|
||||
"jigsaw_block.placement_priority",
|
||||
"jigsaw_block.placement_priority.tooltip",
|
||||
"-5",
|
||||
"3",
|
||||
),
|
||||
] {
|
||||
assert!(items.iter().any(|item| matches!(item,
|
||||
Item::Label { text, x: lx, y, .. } if *lx == x && *y == 150.0 && text.translation_key() == Some(label)
|
||||
)));
|
||||
let index = items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Item::Edit { index, x: ex, y, w } if *ex == x && *y == 160.0 && *w == 98.0 => {
|
||||
Some(*index)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("priority field below its label");
|
||||
assert_eq!(e.edits[index].value, value);
|
||||
assert_eq!(e.edit_tooltip(index).unwrap().translation_key(), Some(tip));
|
||||
e.edits[index].set_value(changed);
|
||||
}
|
||||
let S::SetJigsawBlock(packet) = e.set_packet((0, 0, 0)).unwrap() else {
|
||||
panic!("sends jigsaw fields");
|
||||
};
|
||||
assert_eq!(
|
||||
(packet.selection_priority, packet.placement_priority),
|
||||
(8, 3)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3127,17 +3127,17 @@ impl Game<'_> {
|
||||
self.remote_totem(id);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.onEquippedItemBroken` for the acting player (the guest whose turn it
|
||||
/// is, else the host): `level.broadcastEntityEvent` with `entityEventForEquipmentBreak`
|
||||
/// of the slot, which each client answers with `breakItem` (the sound and the crumbs,
|
||||
/// `remote_equipment_break`). Without a hub to tell, or in a window that runs the world
|
||||
/// itself, the sound and the crumbs are played here.
|
||||
/// `ServerPlayer.onEquippedItemBroken` for the acting player (the guest whose turn it
|
||||
/// is, else the host): the broken statistic, then `level.broadcastEntityEvent` of the
|
||||
/// slot's break event, which each client answers with `breakItem` (the sound and the
|
||||
/// crumbs, `remote_equipment_break`). Without a hub to tell, they are played here.
|
||||
pub(super) fn player_equipment_broke(
|
||||
&mut self,
|
||||
slot: horizon_world::player_inventory::EquipmentSlot,
|
||||
stack: &horizon_client::items::ItemStack,
|
||||
) {
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
self.award_stat("broken", &stack.item, 1);
|
||||
let event = match slot {
|
||||
EquipmentSlot::MainHand => 47,
|
||||
EquipmentSlot::OffHand => 48,
|
||||
|
||||
@@ -2312,7 +2312,7 @@ impl Game<'_> {
|
||||
}
|
||||
4 | 5 => {
|
||||
if !spectator {
|
||||
self.as_guest(host, conn, |g| g.drop_held(a.action == 4));
|
||||
self.guest_drop_held(host, gi, conn, a.action == 4);
|
||||
}
|
||||
}
|
||||
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
|
||||
@@ -4502,7 +4502,7 @@ impl Game<'_> {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(self.world.bottom_y() - 64) => {
|
||||
None if at[1] < self.world.below_world_y() => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
None => self.guest_death_message(conn, me),
|
||||
|
||||
@@ -202,6 +202,8 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |g| {
|
||||
let player = g.native_acting_id();
|
||||
for (recipe, stack) in &item_crafted {
|
||||
// ItemStack.onCraftedBy awards Stats.ITEM_CRAFTED.
|
||||
g.award_stat("crafted", &stack.item, stack.count as i32);
|
||||
g.native_item_crafted(player, recipe, stack);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -573,7 +573,7 @@ impl Game<'_> {
|
||||
let corner = |max: bool| {
|
||||
[Axis::X, Axis::Y, Axis::Z].map(|a| if max { around.max(a) } else { around.min(a) })
|
||||
};
|
||||
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true));
|
||||
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true), &host.hub.encoder);
|
||||
let allow_flight = host.hub.config.allow_flight;
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
|
||||
@@ -119,6 +119,10 @@ impl Loading {
|
||||
}
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(hwnd) = hwnd(&window) {
|
||||
horizon_threads::system_menu::suppress(hwnd);
|
||||
}
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
|
||||
@@ -602,6 +602,9 @@ impl Game<'_> {
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let item_crafted = std::mem::take(&mut menu.item_crafted);
|
||||
for (_, stack) in &item_crafted {
|
||||
if !predicting {
|
||||
self.award_stat("crafted", &stack.item, stack.count as i32);
|
||||
}
|
||||
self.tutorial_get_item(Some(stack));
|
||||
}
|
||||
for (i, stack) in after.into_iter().enumerate() {
|
||||
|
||||
@@ -281,10 +281,7 @@ impl Game<'_> {
|
||||
Some(format!("minecraft:{}", nautilus::saddle_sound(m)))
|
||||
} else {
|
||||
put_on.map(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.and_then(|i| i.equip_sound.clone())
|
||||
horizon_client::item_sync::equip_sound(s, self.res.items())
|
||||
.unwrap_or_else(|| "minecraft:item.armor.equip_generic".into())
|
||||
})
|
||||
};
|
||||
|
||||
@@ -280,7 +280,6 @@ impl Game<'_> {
|
||||
// `hurtAndConvertOnBreak`: the stick breaks (the broken
|
||||
// statistic and `onEquippedItemBroken`), then what is
|
||||
// left is the rod, not a fresh plain one.
|
||||
self.award_stat("broken", &stack.item, 1);
|
||||
self.player_equipment_broke(
|
||||
horizon_world::player_inventory::EquipmentSlot::MainHand,
|
||||
&stack,
|
||||
|
||||
@@ -125,8 +125,7 @@ impl Game<'_> {
|
||||
let mut vertices = Vec::new();
|
||||
self.particles.render(
|
||||
glam::DVec3::from_array(at),
|
||||
0.0,
|
||||
0.0,
|
||||
glam::Quat::IDENTITY,
|
||||
1.0,
|
||||
&|_| 15,
|
||||
&mut vertices,
|
||||
@@ -167,8 +166,7 @@ impl Game<'_> {
|
||||
vertices.clear();
|
||||
self.particles.render(
|
||||
glam::DVec3::from_array(at),
|
||||
0.0,
|
||||
0.0,
|
||||
glam::Quat::IDENTITY,
|
||||
1.0,
|
||||
&|_| 15,
|
||||
&mut vertices,
|
||||
|
||||
@@ -86,6 +86,10 @@ impl Game<'_> {
|
||||
fn take_blast_state(&mut self) -> BlastState {
|
||||
let c = &mut self.combat;
|
||||
BlastState {
|
||||
explicit_cause: c.explicit_explosion_cause.take(),
|
||||
blast_owner: c.blast_owner.take(),
|
||||
blast_guest: c.blast_guest.take(),
|
||||
explicit_direct: c.explicit_explosion_direct.take(),
|
||||
source: c.explosion_source.take(),
|
||||
cause: c.explosion_cause.take(),
|
||||
source_named: std::mem::take(&mut c.explosion_source_named),
|
||||
@@ -93,12 +97,19 @@ impl Game<'_> {
|
||||
hive_emptying: std::mem::take(&mut c.hive_emptying_blast),
|
||||
rail_proof: std::mem::take(&mut c.rail_proof_blast),
|
||||
owner: c.explosion_owner.take(),
|
||||
tnt_owner: c.blast_owner.take(),
|
||||
tnt_guest: c.blast_guest.take(),
|
||||
tnt_owner_player: std::mem::take(&mut c.blast_owner_player),
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts back what `take_blast_state` cleared.
|
||||
fn restore_blast_state(&mut self, s: BlastState) {
|
||||
let c = &mut self.combat;
|
||||
c.explicit_explosion_cause = s.explicit_cause;
|
||||
c.blast_owner = s.blast_owner;
|
||||
c.blast_guest = s.blast_guest;
|
||||
c.explicit_explosion_direct = s.explicit_direct;
|
||||
c.explosion_source = s.source;
|
||||
c.explosion_cause = s.cause;
|
||||
c.explosion_source_named = s.source_named;
|
||||
@@ -106,11 +117,18 @@ impl Game<'_> {
|
||||
c.hive_emptying_blast = s.hive_emptying;
|
||||
c.rail_proof_blast = s.rail_proof;
|
||||
c.explosion_owner = s.owner;
|
||||
c.blast_owner = s.tnt_owner;
|
||||
c.blast_guest = s.tnt_guest;
|
||||
c.blast_owner_player = s.tnt_owner_player;
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of `CombatTracker` that describe the blast going off now.
|
||||
struct BlastState {
|
||||
explicit_cause: Option<Option<super::explosion::ExplosionCause>>,
|
||||
blast_owner: Option<(u32, horizon_client::text::Text)>,
|
||||
blast_guest: Option<u32>,
|
||||
explicit_direct: Option<horizon_client::text::Text>,
|
||||
source: Option<u32>,
|
||||
cause: Option<horizon_client::advancements::EntityView>,
|
||||
source_named: bool,
|
||||
@@ -118,6 +136,9 @@ struct BlastState {
|
||||
hive_emptying: bool,
|
||||
rail_proof: bool,
|
||||
owner: Option<u32>,
|
||||
tnt_owner: Option<(u32, horizon_client::text::Text)>,
|
||||
tnt_guest: Option<u32>,
|
||||
tnt_owner_player: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -366,6 +366,17 @@ impl Game<'_> {
|
||||
view.x_rot,
|
||||
))
|
||||
}
|
||||
/// `Camera.rotation()` as the quads of particles take it: the frame's camera (a
|
||||
/// bed's, a front view's, a mod's), not the player's own yaw and pitch.
|
||||
pub(super) fn particle_rotation(&self) -> glam::Quat {
|
||||
if self.studio.flying() {
|
||||
return self.camera_orientation();
|
||||
}
|
||||
let (yaw, pitch) = self
|
||||
.posed_rotation
|
||||
.unwrap_or((self.view.y_rot, self.view.x_rot));
|
||||
super::camera_rotation(CameraType::FirstPerson, yaw, pitch)
|
||||
}
|
||||
/// The player as `Camera.alignWithEntity` sees it.
|
||||
fn player_camera_entity(&mut self) -> Option<CameraEntity> {
|
||||
let (scale, distance) = {
|
||||
|
||||
@@ -65,12 +65,13 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let at = [at.x, at.y, at.z];
|
||||
let equippable = new
|
||||
.and_then(|s| self.items().get(&s.item))
|
||||
.and_then(|info| {
|
||||
let own = info.equip_slot.as_deref()?;
|
||||
Some((own == slot, info.equip_sound.clone()))
|
||||
});
|
||||
let equippable = new.and_then(|stack| {
|
||||
let equippable = horizon_client::item_sync::equippable(stack, self.items())?;
|
||||
Some((
|
||||
equippable.slot == slot,
|
||||
horizon_client::item_sync::equip_sound(stack, self.items()),
|
||||
))
|
||||
});
|
||||
if let Some((true, sound)) = &equippable {
|
||||
let sound = sound
|
||||
.as_deref()
|
||||
|
||||
@@ -1410,7 +1410,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if deflect_stop {
|
||||
return t.age < 1200 && t.position.y > -128.0;
|
||||
return t.age < 1200 && t.position.y >= self.world.below_world_y();
|
||||
}
|
||||
t.position = next;
|
||||
let bounds = Aabb::new(
|
||||
@@ -1418,7 +1418,7 @@ impl Game<'_> {
|
||||
next.add_vector(Vec3::new(0.125, 0.25, 0.125)),
|
||||
);
|
||||
self.entity_powder_snow_particles(t.previous, next, bounds, t.velocity, false);
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
t.age < 1200 && t.position.y >= self.world.below_world_y()
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
for id in deflected {
|
||||
|
||||
@@ -579,6 +579,9 @@ impl Game<'_> {
|
||||
..last
|
||||
});
|
||||
let mut mob = self.mobs.remove(i);
|
||||
// #15294: the fall, lava or a wall it runs into hurt the server's
|
||||
// horse, not this copy (`Entity.hurt` on a client level).
|
||||
mob.extra.client_side = true;
|
||||
let mut random = self.world.random.clone();
|
||||
let mut events = Vec::new();
|
||||
{
|
||||
|
||||
@@ -268,7 +268,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `Level.getMinY`: the current dimension type's minimum y.
|
||||
fn level_min_y(&self) -> i32 {
|
||||
pub(super) fn level_min_y(&self) -> i32 {
|
||||
match self.remote.as_ref() {
|
||||
Some(r) => r.dimension.min_y,
|
||||
None => self.world.shape.min_y,
|
||||
|
||||
@@ -352,6 +352,7 @@ impl Game<'_> {
|
||||
// ServerList.saveSingleServer.
|
||||
if let Some(address) = &self.server_packs.address {
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let _guard = servers::lock();
|
||||
let (mut list, mut hidden) = servers::load_all(&file);
|
||||
let mut found = false;
|
||||
for entry in list.iter_mut().filter(|s| &s.ip == address) {
|
||||
|
||||
@@ -503,6 +503,8 @@ impl Game<'_> {
|
||||
},
|
||||
|id| self.mob_display_name(id),
|
||||
);
|
||||
self.combat.blast_owner_player =
|
||||
owner == Some(Who::Player) && self.combat.blast_owner.is_some();
|
||||
let result = self.with_hive_emptying_blast(|g| {
|
||||
g.server_explode_from(
|
||||
center,
|
||||
@@ -515,6 +517,7 @@ impl Game<'_> {
|
||||
)
|
||||
});
|
||||
self.combat.blast_owner = None;
|
||||
self.combat.blast_owner_player = false;
|
||||
self.combat.blast_guest = None;
|
||||
self.combat.explosion_in_water = false;
|
||||
result?;
|
||||
|
||||
@@ -1084,12 +1084,16 @@ impl Game<'_> {
|
||||
}
|
||||
self.thrower_dealt_absorbed(t.guest);
|
||||
// ThrownTrident.onHitEntity on a boat or minecart:
|
||||
// VehicleEntity.hurtServer takes the damage.
|
||||
// VehicleEntity.hurtServer takes the damage; an item
|
||||
// frame's ItemFrame.hurtServer.
|
||||
let creative = t.guest.is_none() && self.creative;
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.hurt_boat(i, damage, creative);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.hurt_cart(i, damage, creative, false);
|
||||
} else {
|
||||
// ItemFrame.hurtServer: the item comes out, or the frame breaks.
|
||||
self.projectile_hurts_frame(id, true, creative);
|
||||
}
|
||||
// ThrownTrident.onHitEntity on another player.
|
||||
if let Some(conn) = self.trident_guest_conn(id) {
|
||||
|
||||
@@ -145,7 +145,8 @@ impl Game<'_> {
|
||||
burst
|
||||
}
|
||||
/// A projectile's hurt of `amount` on the boat or minecart `id`
|
||||
/// (`VehicleEntity.hurtServer`): even 0 shakes it (`Snowball`, `ThrownEgg`,
|
||||
/// (`VehicleEntity.hurtServer`), or the item frame (`ItemFrame.hurtServer`,
|
||||
/// which any non-explosion hit makes give up its item): even 0 shakes it (`Snowball`, `ThrownEgg`,
|
||||
/// `ThrownEnderpearl` `.onHitEntity`), 5 from a small fireball destroys it.
|
||||
pub(super) fn hurt_vehicle(&mut self, p: &Projectile, id: u32, amount: f32) {
|
||||
let creative = p.owner == Some(Who::Player) && self.creative;
|
||||
@@ -153,6 +154,8 @@ impl Game<'_> {
|
||||
self.hurt_boat(i, amount, creative);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.hurt_cart(i, amount, creative, false);
|
||||
} else {
|
||||
self.projectile_hurts_frame(id, p.owner == Some(Who::Player), creative);
|
||||
}
|
||||
}
|
||||
/// The boats and minecarts of this tick's projectile pass.
|
||||
|
||||
@@ -314,6 +314,10 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(hwnd) = hwnd(&window) {
|
||||
horizon_threads::system_menu::suppress(hwnd);
|
||||
}
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
|
||||
@@ -194,6 +194,39 @@ pub fn equippable(stack: &ItemStack, items: &crate::items::Items) -> Option<Equi
|
||||
out
|
||||
}
|
||||
|
||||
/// `Equippable.equipSound` from the stack's component, else the item's.
|
||||
/// A present component without the field is the generic armour sound (None),
|
||||
/// not the prototype's.
|
||||
pub fn equip_sound(stack: &ItemStack, items: &crate::items::Items) -> Option<String> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(tag) = data.and_then(|d| d.other("minecraft:equippable")) {
|
||||
return match tag.get("equip_sound") {
|
||||
Some(Tag::String(sound)) => Some(sound.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
items.get(&stack.item).and_then(|i| i.equip_sound.clone())
|
||||
}
|
||||
|
||||
/// `Equippable.damageOnHurt`: a present component's field (default true),
|
||||
/// a removed component false, else the item's prototype.
|
||||
pub fn damage_on_hurt(stack: &ItemStack, default: bool) -> bool {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(tag) = data.and_then(|d| d.other("minecraft:equippable")) {
|
||||
return tag
|
||||
.get("damage_on_hurt")
|
||||
.and_then(crate::item_data::component_bool)
|
||||
.unwrap_or(true);
|
||||
}
|
||||
default
|
||||
}
|
||||
|
||||
/// `stack.get(DataComponents.CONSUMABLE)`: the item's own, replaced or removed
|
||||
/// by the saved components (a command's, a save's), then by the server's patch.
|
||||
pub fn consumable(stack: &ItemStack, items: &crate::items::Items) -> Option<ItemConsumable> {
|
||||
@@ -1124,7 +1157,71 @@ pub fn profile_tag(p: &Profile) -> Tag {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::registry::Ids;
|
||||
use std::path::Path;
|
||||
|
||||
/// `Equippable.equipSound` is the stack's component's own: a replaced
|
||||
/// component without one plays the generic sound, a removed one none.
|
||||
#[test]
|
||||
fn equippable_equip_sound_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:golden_helmet", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:equippable",
|
||||
Some(Tag::compound([("slot", Tag::String("head".into()))])),
|
||||
);
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let items =
|
||||
crate::items::Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
|
||||
assert_eq!(equip_sound(&stack, &items), None);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:equippable",
|
||||
Some(Tag::compound([
|
||||
("slot", Tag::String("head".into())),
|
||||
(
|
||||
"equip_sound",
|
||||
Tag::String("minecraft:item.armor.equip_gold".into()),
|
||||
),
|
||||
])),
|
||||
);
|
||||
assert_eq!(
|
||||
equip_sound(&stack, &items).as_deref(),
|
||||
Some("minecraft:item.armor.equip_gold")
|
||||
);
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:equippable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(equip_sound(&stack, &items), None);
|
||||
}
|
||||
/// `Equippable.damageOnHurt` is the stack's component's own: true when a
|
||||
/// replaced component leaves it out, false when the component is removed.
|
||||
#[test]
|
||||
fn equippable_damage_on_hurt_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:golden_helmet", 1);
|
||||
assert!(damage_on_hurt(&stack, true));
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:equippable",
|
||||
Some(Tag::compound([
|
||||
("slot", Tag::String("head".into())),
|
||||
("damage_on_hurt", Tag::Byte(0)),
|
||||
])),
|
||||
);
|
||||
assert!(!damage_on_hurt(&stack, true));
|
||||
assert!(!damage_on_hurt(&stack, false));
|
||||
assert!(!damage_on_hurt(
|
||||
&ItemStack::new("minecraft:golden_helmet", 1),
|
||||
false
|
||||
));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:equippable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(!damage_on_hurt(&stack, true));
|
||||
}
|
||||
/// A chestplate a server made a glider: its patch adds `glider` and an
|
||||
/// `equippable` for the chest, or takes `glider` away again.
|
||||
#[test]
|
||||
|
||||
@@ -906,6 +906,22 @@ impl Guest {
|
||||
self.menu_sent[slot] = self.menu_slot(slot);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.drop(boolean)`: `setRemoteSlot` on the selected hotbar
|
||||
/// slot of the open menu (the inventory menu without one) records what the
|
||||
/// dropping client already predicted, so the next broadcast sends the slot
|
||||
/// again once it changes back (a pickup refilling the stack).
|
||||
pub fn told_selected(&mut self) {
|
||||
let slot = 36 + self.selected;
|
||||
self.menu_sent[slot] = self.menu_slot(slot);
|
||||
let told = self.inventory.get(self.selected).cloned().flatten();
|
||||
if let Some(m) = self.menu.as_mut() {
|
||||
let at = m.size + 27 + self.selected;
|
||||
if m.sent.len() <= at {
|
||||
m.sent.resize(at + 1, None);
|
||||
}
|
||||
m.sent[at] = told;
|
||||
}
|
||||
}
|
||||
pub fn set_menu_slot(&mut self, slot: usize, item: Option<ItemStack>) {
|
||||
match slot {
|
||||
0 => self.craft_result = item,
|
||||
@@ -2303,4 +2319,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn told_selected_records_the_predicted_drop() {
|
||||
use crate::lan::PlayerSender;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicI32;
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 2,
|
||||
name: "Dropper".into(),
|
||||
properties: Vec::new(),
|
||||
info: horizon_protocol::packets::configuration::ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[0.5, 64.0, 0.5],
|
||||
0,
|
||||
);
|
||||
guest.selected = 3;
|
||||
guest.inventory[3] = Some(ItemStack::new("minecraft:netherrack", 63));
|
||||
guest.told_selected();
|
||||
// Dropping one is already known to the client (63); the pickup that
|
||||
// refills the slot must then differ from `menu_sent` and be sent.
|
||||
assert_eq!(guest.menu_sent[39].as_ref().map(|s| s.count), Some(63));
|
||||
guest.inventory[3].as_mut().unwrap().count = 64;
|
||||
assert_ne!(guest.menu_sent[39], guest.menu_slot(39));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ use horizon_world::{
|
||||
};
|
||||
use std::sync::{Arc, Mutex, atomic::Ordering};
|
||||
|
||||
mod accept_teleport;
|
||||
mod acknowledgements;
|
||||
/// GameEvent types (ClientboundGameEventPacket).
|
||||
pub mod chat;
|
||||
@@ -3953,32 +3954,7 @@ impl Hub {
|
||||
| S::PlayerCommand(_)
|
||||
if !self.guests[i].client_loaded() => {}
|
||||
// handleAcceptTeleportPacket.
|
||||
S::AcceptTeleportation(p) => {
|
||||
let recording = self.recording();
|
||||
let g = &mut self.guests[i];
|
||||
if let Some((id, at)) = g.awaiting_teleport
|
||||
&& id == p.id
|
||||
{
|
||||
if p.x.is_nan()
|
||||
|| p.y.is_nan()
|
||||
|| p.z.is_nan()
|
||||
|| !p.yaw.is_finite()
|
||||
|| !p.pitch.is_finite()
|
||||
{
|
||||
self.kick(conn, invalid_movement());
|
||||
continue;
|
||||
}
|
||||
g.position = at;
|
||||
g.awaiting_teleport = None;
|
||||
// The prediction's replay starts again where the client now is.
|
||||
if recording {
|
||||
let kept = g.awaiting_keeps_motion;
|
||||
g.record.get_or_insert_default().teleported(at, kept);
|
||||
}
|
||||
let rot = (p.yaw, p.pitch);
|
||||
self.position_change(i, [p.x, p.y, p.z], rot, 0, ctx, events);
|
||||
}
|
||||
}
|
||||
S::AcceptTeleportation(p) => self.accept_teleport(i, conn, &p, ctx, events),
|
||||
// handlePlayerAbilities: the client flies only if it may.
|
||||
S::PlayerAbilities(p) => {
|
||||
let g = &mut self.guests[i];
|
||||
@@ -4480,7 +4456,7 @@ impl Hub {
|
||||
at[1] + f64::from(d.height) + 0.0625,
|
||||
at[2] + half,
|
||||
];
|
||||
no_blocks_in(world, lo, hi)
|
||||
no_blocks_in(world, lo, hi, &self.encoder)
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.forceSendPlayerSupportBlocks`: the blocks just under the
|
||||
@@ -5084,6 +5060,9 @@ impl Hub {
|
||||
let g = &mut self.guests[i];
|
||||
g.landing = true;
|
||||
g.awaiting_teleport = None;
|
||||
// The new level's first teleport takes the next id, so the old one's late accept
|
||||
// is no longer the last id sent.
|
||||
g.teleport_counter = (g.teleport_counter + 1) & 0x7FFF_FFFF;
|
||||
} else {
|
||||
self.resend_teleport(i, None);
|
||||
}
|
||||
@@ -5168,14 +5147,14 @@ const PLAYER_GRAVITY: f64 = 0.08;
|
||||
|
||||
/// `ServerGamePacketListenerImpl.getMaximumFlyingTicks`: 80 ticks, more where
|
||||
/// gravity is weaker, none at all (never kicked) without gravity.
|
||||
/// `ServerGamePacketListenerImpl.noBlocksAround` over a box: every block from `lo` to `hi` is air, in
|
||||
/// loaded chunks (a chunk not loaded yet is not known to be air: nothing floats there).
|
||||
pub fn no_blocks_in(world: &World, lo: [f64; 3], hi: [f64; 3]) -> bool {
|
||||
/// `ServerGamePacketListenerImpl.noBlocksAround` over a box: every block from `lo` to `hi` is
|
||||
/// `isAir()` (`air`'s state table), in loaded chunks (a chunk not loaded yet is not known to be air).
|
||||
pub fn no_blocks_in(world: &World, lo: [f64; 3], hi: [f64; 3], air: &ChunkEncoder) -> bool {
|
||||
let cell = |v: f64| v.floor() as i32;
|
||||
(cell(lo[0])..=cell(hi[0])).all(|x| {
|
||||
(cell(lo[2])..=cell(hi[2])).all(|z| {
|
||||
world.loaded(x, z)
|
||||
&& (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z)))
|
||||
&& (cell(lo[1])..=cell(hi[1])).all(|y| air.is_air(world.block(x, y, z).0))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! `ServerGamePacketListenerImpl.handleAcceptTeleportPacket`.
|
||||
use super::*;
|
||||
|
||||
impl Hub {
|
||||
/// An accept of `awaitingTeleport`, the last id sent: with no teleport awaited
|
||||
/// (`awaitingPositionFromClient` null) or a non-finite position it is invalid
|
||||
/// movement and disconnects; otherwise the player is where the teleport put them.
|
||||
/// An id that is not the last one sent is ignored.
|
||||
pub(super) fn accept_teleport(
|
||||
&mut self,
|
||||
i: usize,
|
||||
conn: u32,
|
||||
p: &play::AcceptTeleportation,
|
||||
ctx: &mut TickCtx,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) {
|
||||
let recording = self.recording();
|
||||
let g = &mut self.guests[i];
|
||||
if p.id != g.teleport_counter {
|
||||
return;
|
||||
}
|
||||
let Some((_, at)) = g.awaiting_teleport else {
|
||||
self.kick(conn, invalid_movement());
|
||||
return;
|
||||
};
|
||||
if p.x.is_nan()
|
||||
|| p.y.is_nan()
|
||||
|| p.z.is_nan()
|
||||
|| !p.yaw.is_finite()
|
||||
|| !p.pitch.is_finite()
|
||||
{
|
||||
self.kick(conn, invalid_movement());
|
||||
return;
|
||||
}
|
||||
g.position = at;
|
||||
g.awaiting_teleport = None;
|
||||
// The prediction's replay starts again where the client now is.
|
||||
if recording {
|
||||
let kept = g.awaiting_keeps_motion;
|
||||
g.record.get_or_insert_default().teleported(at, kept);
|
||||
}
|
||||
let rot = (p.yaw, p.pitch);
|
||||
self.position_change(i, [p.x, p.y, p.z], rot, 0, ctx, events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! `ServerGamePacketListenerImpl.noBlocksAround` filters the player's box on
|
||||
//! `BlockBehaviour.BlockStateBase::isAir`, so the air of a cave (`cave_air`) lets nothing
|
||||
//! block it: a survival guest hovering in a mineshaft is floating, and the server kicks it
|
||||
//! after `getMaximumFlyingTicks` (80) ticks with "Flying is not enabled on this server".
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_guest_hovering_in_cave_air_is_kicked_for_flying() {
|
||||
// The prepared 26.3 data: the real cave_air state, its facts and collision shapes.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("cached-collision.nbt").exists() {
|
||||
return; // CI has no vanilla data.
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let facts = crate::blocks::BlockFacts::load(&root, registry).unwrap();
|
||||
let collision = crate::collisions::load(&root, registry).unwrap();
|
||||
let air = registry.default_state("minecraft:air").unwrap();
|
||||
let cave_air = registry.default_state("minecraft:cave_air").unwrap();
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let (mut hub, _rx) = hub_with_a_guest();
|
||||
// The hub's state table knows the real air blocks, as the game's does.
|
||||
hub.encoder = ChunkEncoder::new(
|
||||
facts
|
||||
.states
|
||||
.iter()
|
||||
.map(|s| (s.name.clone(), s.fluid_level > 0 || s.waterlogged)),
|
||||
&horizon_protocol::registry::Builtin::default(),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
hub.guests[0].position = [0.5, 64.0, 0.5];
|
||||
// The body's box (0.6 wide, 1.8 tall at 64) covers (0, 63..=65, 0): a cave's air.
|
||||
let mut world = World::new(air, 0);
|
||||
let mut chunk = crate::chunk::Chunk::empty(0, 0);
|
||||
for y in 63..=65 {
|
||||
chunk.set(0, y, 0, cave_air.0);
|
||||
}
|
||||
chunk.lit = true;
|
||||
world.chunks.insert((0, 0), std::sync::Arc::new(chunk));
|
||||
assert!(
|
||||
hub.no_blocks_around(0, [0.5, 64.0, 0.5], &world),
|
||||
"cave_air is air"
|
||||
);
|
||||
// One stone block in the box is not air (a cave_air block is not a hole in this test).
|
||||
world.set(0, 64, 0, stone).unwrap();
|
||||
assert!(!hub.no_blocks_around(0, [0.5, 64.0, 0.5], &world));
|
||||
world.set(0, 64, 0, cave_air).unwrap();
|
||||
|
||||
// A survival guest hovering there: no step up, nothing to stand on, nothing around.
|
||||
let mut events = Vec::new();
|
||||
let border = crate::worldborder::WorldBorder::default();
|
||||
let mut next_entity = 2;
|
||||
let ctx = TickCtx {
|
||||
sea_level: 63,
|
||||
world: &mut world,
|
||||
entities: &[],
|
||||
next_entity: &mut next_entity,
|
||||
spawn: [0.5, 64.0, 0.5],
|
||||
spawn_angle: [0.0; 2],
|
||||
difficulty: 2,
|
||||
difficulty_locked: false,
|
||||
clock_rate: 1.0,
|
||||
tick_rate: 20.0,
|
||||
tick_frozen: false,
|
||||
tick_steps: Default::default(),
|
||||
collision: Some(&collision),
|
||||
border: &border,
|
||||
streaming: None,
|
||||
};
|
||||
hub.position_change(0, [0.5, 64.0, 0.5], (0.0, 0.0), 0, &ctx, &mut events);
|
||||
assert!(hub.guests[0].client_is_floating);
|
||||
// getMaximumFlyingTicks(0.08) is 80: the 81st tick is the one that kicks.
|
||||
for _ in 0..80 {
|
||||
hub.tick_floating(0, &mut events);
|
||||
}
|
||||
assert!(events.is_empty(), "80 ticks of hovering are allowed");
|
||||
hub.tick_floating(0, &mut events);
|
||||
assert!(
|
||||
matches!(&events[..], [HostEvent::Warn(line)] if line.contains("floating too long")),
|
||||
"one kick warning"
|
||||
);
|
||||
assert!(
|
||||
matches!(&hub.pending_kicks[..],
|
||||
[HostEvent::Kicked { reason, .. }] if reason.to_lowercase().contains("flying")),
|
||||
"the guest is kicked for flying"
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#[path = "hub/tests/cave_air_tests.rs"]
|
||||
mod cave_air_tests;
|
||||
#[path = "hub/far_tests.rs"]
|
||||
mod far_tests;
|
||||
#[path = "hub/tests/sound_tests.rs"]
|
||||
@@ -1378,3 +1380,41 @@ fn memory_connection_first_view_sends_every_ready_column() {
|
||||
"an empty queue sends nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleAcceptTeleportPacket`: an accept whose id is
|
||||
/// the last teleport's (`awaitingTeleport`) while none is awaited any more
|
||||
/// (`awaitingPositionFromClient` is null) disconnects with
|
||||
/// `multiplayer.disconnect.invalid_player_movement`; an accept of any other id is
|
||||
/// ignored, and the first accept of the awaited one moves the player as before.
|
||||
#[test]
|
||||
fn a_second_accept_of_the_same_teleport_disconnects() {
|
||||
let (mut hub, _rx) = hub_with_a_guest();
|
||||
let mut world = floor_at(0);
|
||||
let accept = |id: i32| {
|
||||
S::AcceptTeleportation(play::AcceptTeleportation {
|
||||
id,
|
||||
x: 5.0,
|
||||
y: 70.0,
|
||||
z: 5.0,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
})
|
||||
};
|
||||
hub.teleport(0, Some([5.0, 70.0, 5.0]));
|
||||
let id = hub.guests[0].teleport_counter;
|
||||
assert!(hub.guests[0].awaiting_teleport.is_some());
|
||||
// An id nobody sent is ignored while one is awaited.
|
||||
tick_packet(&mut hub, &mut world, accept(id + 1));
|
||||
assert!(hub.guests[0].awaiting_teleport.is_some());
|
||||
assert!(hub.guests[0].left_reason.is_empty());
|
||||
// The awaited one is accepted once.
|
||||
tick_packet(&mut hub, &mut world, accept(id));
|
||||
assert!(hub.guests[0].awaiting_teleport.is_none());
|
||||
assert!(hub.guests[0].left_reason.is_empty());
|
||||
// Another id with nothing awaited is ignored.
|
||||
tick_packet(&mut hub, &mut world, accept(id + 1));
|
||||
assert!(hub.guests[0].left_reason.is_empty());
|
||||
// The same id again, with nothing awaited: disconnected.
|
||||
tick_packet(&mut hub, &mut world, accept(id));
|
||||
assert!(!hub.guests[0].left_reason.is_empty());
|
||||
}
|
||||
@@ -2745,12 +2745,7 @@ impl Menu {
|
||||
if after_take || matches!(self.kind, Kind::Inventory | Kind::Crafting) {
|
||||
// A merchant result reports the stack it moved, a crafting result the copy it
|
||||
// takes (which Item.onCraftedBy may still change).
|
||||
let taken = if self.slots[index].role == Role::MerchantResult {
|
||||
&mut stack
|
||||
} else {
|
||||
&mut clicked
|
||||
};
|
||||
self.on_take(env, index, taken);
|
||||
self.take_after_quick_move(env, index, &mut clicked, &mut stack);
|
||||
}
|
||||
// What the result slot's move left over is dropped.
|
||||
if (index == 0 && matches!(self.kind, Kind::Inventory | Kind::Crafting))
|
||||
|
||||
@@ -316,6 +316,29 @@ struct Grid {
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
/// `on_take` for a quick-moved slot. A merchant result reports the stack it moved. A
|
||||
/// crafting result reports the copy it takes (which Item.onCraftedBy may still change)
|
||||
/// minus what stayed behind: `ResultSlot.onQuickCraft` counts only the items that moved.
|
||||
pub(super) fn take_after_quick_move(
|
||||
&mut self,
|
||||
env: &Env,
|
||||
index: usize,
|
||||
clicked: &mut ItemStack,
|
||||
stack: &mut ItemStack,
|
||||
) {
|
||||
if self.slots[index].role == super::Role::MerchantResult {
|
||||
return self.on_take(env, index, stack);
|
||||
}
|
||||
let left = if self.slots[index].role == super::Role::CraftResult {
|
||||
stack.count
|
||||
} else {
|
||||
0
|
||||
};
|
||||
clicked.count -= left;
|
||||
self.on_take(env, index, clicked);
|
||||
clicked.count += left;
|
||||
}
|
||||
|
||||
fn place_grid(&self) -> Option<Grid> {
|
||||
match self.kind {
|
||||
Kind::Inventory => Some(Grid {
|
||||
|
||||
@@ -127,6 +127,29 @@ fn a_transfer_of_all_fills_as_many_sets_as_the_items_allow() {
|
||||
});
|
||||
}
|
||||
|
||||
/// A quick-moved crafting result counts only the items that moved, not the
|
||||
/// whole result stack (`ResultSlot.onQuickCraft`).
|
||||
#[test]
|
||||
fn quick_move_crafting_records_only_items_moved() {
|
||||
for kind in [Kind::Crafting, Kind::Inventory] {
|
||||
with_menu(kind, |env, menu| {
|
||||
menu.creative = false;
|
||||
menu.set_slot(env, 1, stack("oak_log", 1));
|
||||
let start = if kind == Kind::Crafting { 10 } else { 9 };
|
||||
let end = if kind == Kind::Crafting { 46 } else { 45 };
|
||||
for slot in start..end {
|
||||
menu.set_slot(env, slot, stack("stone", 64));
|
||||
}
|
||||
menu.set_slot(env, end - 1, stack("oak_planks", 62));
|
||||
menu.settle(env);
|
||||
menu.click(env, 0, 0, crate::menu::Input::QuickMove)
|
||||
.unwrap();
|
||||
assert_eq!(menu.slot(end - 1).unwrap().count, 64);
|
||||
assert_eq!(menu.item_crafted[0].1.count, 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A viewer's transfer takes a worn tool, as crafting by hand does (JEI's
|
||||
/// "+"), while the recipe book's placement keeps vanilla's
|
||||
/// `isUsableForCrafting` and leaves it.
|
||||
|
||||
@@ -82,6 +82,7 @@ pub mod random_textures;
|
||||
pub mod ravager;
|
||||
pub mod render;
|
||||
pub mod riders;
|
||||
mod seen;
|
||||
pub mod seen_grid;
|
||||
pub mod sheep_dye;
|
||||
pub mod shulker;
|
||||
@@ -112,6 +113,9 @@ pub mod warden;
|
||||
pub mod warden_brain;
|
||||
pub mod witch;
|
||||
pub mod wither;
|
||||
|
||||
#[cfg(test)]
|
||||
mod invisible_follow_tests;
|
||||
mod world;
|
||||
pub use world::{TargetInfo, single_player_pos, visual_clip};
|
||||
pub mod dragon_contacts;
|
||||
@@ -127,221 +131,10 @@ use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Polygon {
|
||||
pub normal: [f32; 3],
|
||||
pub vertices: [[f32; 5]; 4],
|
||||
/// The model's render type culls back faces (`entityCutoutCull`: the bat,
|
||||
/// the baby turtle).
|
||||
pub cull_back: bool,
|
||||
}
|
||||
impl Polygon {
|
||||
fn vertex_normal(&self, pose: ModelNormals) -> [i8; 4] {
|
||||
let n = pose.matrix * glam::Vec3::from_array(self.normal);
|
||||
let n = if pose.trusted {
|
||||
n
|
||||
} else {
|
||||
n.normalize_or_zero()
|
||||
};
|
||||
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
let w = if self.cull_back {
|
||||
crate::mesher::SHADED_CULL_W
|
||||
} else {
|
||||
-127
|
||||
};
|
||||
[packed[0], packed[1], packed[2], w]
|
||||
}
|
||||
}
|
||||
mod model;
|
||||
use model::ModelNormals;
|
||||
pub use model::{Part, Polygon};
|
||||
|
||||
/// ModelPart uses a separate normal stack: uniform scaling keeps normal lengths,
|
||||
/// while nonuniform scaling requires normalization after the complete part transform.
|
||||
#[derive(Clone, Copy)]
|
||||
struct ModelNormals {
|
||||
matrix: glam::Mat3,
|
||||
trusted: bool,
|
||||
}
|
||||
impl ModelNormals {
|
||||
// shortcut: infer the root normal pose from its geometry matrix; pass an explicit
|
||||
// root normal stack when root-transform rounding is ported as well.
|
||||
fn new(base: glam::Mat4) -> Self {
|
||||
let m = glam::Mat3::from_mat4(base);
|
||||
let lengths = glam::Vec3::new(m.x_axis.length(), m.y_axis.length(), m.z_axis.length());
|
||||
let uniform =
|
||||
lengths.min_element() > 0.0 && lengths.max_element() - lengths.min_element() < 1.0e-6;
|
||||
Self {
|
||||
matrix: if uniform {
|
||||
m / lengths.x
|
||||
} else {
|
||||
m.inverse().transpose()
|
||||
},
|
||||
trusted: uniform,
|
||||
}
|
||||
}
|
||||
fn rotation(x: f32, y: f32, z: f32) -> glam::Mat3 {
|
||||
// JOML derives cosine from the rounded sine, then retains quaternion length.
|
||||
// Assuming an exactly unit quaternion changes a packed normal by one byte.
|
||||
let sin_cos = |angle: f32| {
|
||||
let angle = angle * 0.5;
|
||||
let sin = f64::from(angle).sin() as f32;
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let a = angle + std::f32::consts::FRAC_PI_2;
|
||||
let tau = std::f32::consts::TAU;
|
||||
let mut quadrant = a - (a / tau) as i32 as f32 * tau;
|
||||
if quadrant < 0.0 {
|
||||
quadrant += tau;
|
||||
}
|
||||
(
|
||||
sin,
|
||||
if quadrant >= std::f32::consts::PI {
|
||||
-cos
|
||||
} else {
|
||||
cos
|
||||
},
|
||||
)
|
||||
};
|
||||
let (sx, cx) = sin_cos(x);
|
||||
let (sy, cy) = sin_cos(y);
|
||||
let (sz, cz) = sin_cos(z);
|
||||
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
|
||||
let (w, x, y, z) = (
|
||||
cx * cycz + sx * sysz,
|
||||
sx * cycz - cx * sysz,
|
||||
cx * sycz + sx * cysz,
|
||||
cx * cysz - sx * sycz,
|
||||
);
|
||||
let (ww, xx, yy, zz) = (w * w, x * x, y * y, z * z);
|
||||
let (xy, xz, xw, yz, yw, zw) = (x * y, x * z, x * w, y * z, y * w, z * w);
|
||||
glam::Mat3::from_cols_array(&[
|
||||
ww + xx - zz - yy,
|
||||
(xy + xy) + (zw + zw),
|
||||
(xz + xz) - (yw + yw),
|
||||
(xy + xy) - (zw + zw),
|
||||
yy - zz + ww - xx,
|
||||
(yz + yz) + (xw + xw),
|
||||
(yw + yw) + (xz + xz),
|
||||
(yz + yz) - (xw + xw),
|
||||
zz - yy - xx + ww,
|
||||
])
|
||||
}
|
||||
fn part(mut self, pose: [f32; 9]) -> Self {
|
||||
self.matrix *= Self::rotation(pose[3], pose[4], pose[5]);
|
||||
let scale = glam::Vec3::new(pose[6], pose[7], pose[8]);
|
||||
if scale.x.abs() == scale.y.abs() && scale.x.abs() == scale.z.abs() {
|
||||
if scale.min_element() < 0.0 {
|
||||
self.matrix *= glam::Mat3::from_diagonal(scale.signum());
|
||||
}
|
||||
} else {
|
||||
self.matrix *= glam::Mat3::from_diagonal(scale.recip());
|
||||
self.trusted = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
fn part_rotated(mut self, pose: [f32; 9], rotation: glam::Mat3) -> Self {
|
||||
self.matrix *= rotation;
|
||||
let scale = glam::Vec3::new(pose[6], pose[7], pose[8]);
|
||||
if scale.x.abs() == scale.y.abs() && scale.x.abs() == scale.z.abs() {
|
||||
if scale.min_element() < 0.0 {
|
||||
self.matrix *= glam::Mat3::from_diagonal(scale.signum());
|
||||
}
|
||||
} else {
|
||||
self.matrix *= glam::Mat3::from_diagonal(scale.recip());
|
||||
self.trusted = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Part {
|
||||
pub name: String,
|
||||
/// x, y, z, xRot, yRot, zRot, xScale, yScale, zScale
|
||||
pub pose: [f32; 9],
|
||||
pub polygons: Vec<Polygon>,
|
||||
pub children: Vec<Part>,
|
||||
}
|
||||
impl Part {
|
||||
/// Marks every polygon as drawn by a back-face-culling render type.
|
||||
fn set_cull_back(&mut self) {
|
||||
for poly in &mut self.polygons {
|
||||
poly.cull_back = true;
|
||||
}
|
||||
for child in &mut self.children {
|
||||
child.set_cull_back();
|
||||
}
|
||||
}
|
||||
fn parse(name: &str, v: &Value) -> Self {
|
||||
let pose: Vec<f32> = v["pose"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
|
||||
.collect();
|
||||
let mut p = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
|
||||
for (i, x) in pose.iter().enumerate().take(9) {
|
||||
p[i] = *x;
|
||||
}
|
||||
let polygons: Vec<Polygon> = v["polygons"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(index, poly)| {
|
||||
let verts: Vec<[f32; 5]> = poly
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|vx| {
|
||||
let a: Vec<f32> = vx
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
|
||||
.collect();
|
||||
[a[0], a[1], a[2], a[3], a[4]]
|
||||
})
|
||||
.collect();
|
||||
(verts.len() == 4).then(|| Polygon {
|
||||
normal: v["normals"][index].as_array().map_or_else(
|
||||
|| {
|
||||
// shortcut: collapsed legacy faces have no recoverable normal; re-prepare resources.
|
||||
let p = verts
|
||||
.iter()
|
||||
.map(|v| glam::Vec3::new(v[0], v[1], v[2]))
|
||||
.collect::<Vec<_>>();
|
||||
(p[1] - p[0])
|
||||
.cross(p[2] - p[0])
|
||||
.normalize_or_zero()
|
||||
.to_array()
|
||||
},
|
||||
|n| std::array::from_fn(|i| n[i].as_f64().unwrap_or(0.0) as f32),
|
||||
),
|
||||
vertices: [verts[0], verts[1], verts[2], verts[3]],
|
||||
cull_back: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut children: Vec<Part> = v["children"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| Part::parse(k, c))
|
||||
.collect();
|
||||
children.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
// A model whose root holds a part also named "root" (allay, vex,
|
||||
// frog...): that inner part is what `root.getChild("root")` and the
|
||||
// keyframe bones mean, so the outer one gives up the name.
|
||||
let name = if name == "root" && children.iter().any(|c| c.name == "root") {
|
||||
""
|
||||
} else {
|
||||
name
|
||||
};
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
pose: p,
|
||||
polygons,
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Kept for callers of the earlier simple AI; species now select behaviour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Brain {
|
||||
@@ -1080,10 +873,10 @@ pub struct Attrs {
|
||||
}
|
||||
/// `minecraft:wolf_armor`'s maximum damage.
|
||||
pub const WOLF_ARMOR_DURABILITY: i32 = 64;
|
||||
|
||||
/// Species-specific persistent state (variants, colours, taming, ageing...).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Extra {
|
||||
pub client_side: bool, // `Level.isClientSide`: a client copy, which `Entity.hurt` leaves alone.
|
||||
/// Saved fish/axolotl bucket flag; tadpoles save a constant true value.
|
||||
pub from_bucket: bool,
|
||||
/// Command-created armor instances, shared by commands and runtime damage.
|
||||
@@ -1439,7 +1232,6 @@ impl Extra {
|
||||
self.main_hand.take()
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the boxes of a set of mobs reach from their positions: the widest reach sideways, below
|
||||
/// and above among them, worked out from each mob's real box ([`Mob::bounds`]: a shulker's grows
|
||||
/// toward its open lid, sideways or below its position). A box query over many mobs (a vehicle's,
|
||||
@@ -2578,6 +2370,8 @@ pub struct Seen {
|
||||
pub delay: i32,
|
||||
/// `getLastHurtByMob` and its timestamp.
|
||||
pub hurt_by: Option<(Who, i32)>,
|
||||
/// `Entity.isInvisible`: the flag, or the invisibility effect.
|
||||
pub invisible: bool,
|
||||
/// `PathfinderMob.isPanicking`: a running panic goal or the brain's
|
||||
/// `IS_PANICKING` memory.
|
||||
pub panicking: bool,
|
||||
@@ -2773,90 +2567,6 @@ impl Mob {
|
||||
self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE
|
||||
}
|
||||
|
||||
/// What other mobs see of this one this tick (`_player` is unused: it keyed the villager
|
||||
/// reputation that nothing read). Inline: the game's snapshot of every mob writes each one in
|
||||
/// its slot, not through a copy.
|
||||
#[inline]
|
||||
pub fn seen(&self, _player: Option<[i32; 4]>) -> Seen {
|
||||
let m = self;
|
||||
let villager = m.species() == Species::Villager;
|
||||
Seen {
|
||||
in_water: self.fluids.in_water,
|
||||
playing_dead: axolotl::is_playing_dead(self),
|
||||
last_damage: self.last_damage.map(|(d, _)| d),
|
||||
id: m.id,
|
||||
species: m.species(),
|
||||
position: m.position,
|
||||
bounds: m.bounds(),
|
||||
hitbox: m.hitbox(),
|
||||
eye_y: m.eye_y(),
|
||||
alive: m.alive(),
|
||||
baby: m.is_baby(),
|
||||
// A villager is "in love" when it is willing to breed (`canBreed`).
|
||||
in_love: if villager {
|
||||
villager::can_breed(m)
|
||||
} else {
|
||||
m.extra.in_love > 0
|
||||
},
|
||||
tame: m.extra.tame,
|
||||
bred: m.extra.bred,
|
||||
owner: m.extra.owner,
|
||||
hostile: m.is_enemy(),
|
||||
target: m.ai.target,
|
||||
health: m.health,
|
||||
variant: m.extra.variant,
|
||||
size: m.extra.size,
|
||||
color: m.extra.color,
|
||||
job: villager.then_some(m.extra.home).flatten(),
|
||||
bed: villager.then_some(m.extra.bed).flatten(),
|
||||
yaw: m.yaw,
|
||||
velocity: m.velocity,
|
||||
known_speed: m.position.subtract(m.previous),
|
||||
leash: m.extra.leash,
|
||||
caravan: caravan::head_of(m),
|
||||
school: (m.extra.leader, m.extra.followers),
|
||||
section: (m.extra.section, m.extra.section_seq),
|
||||
delay: m.extra.ints[5],
|
||||
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
|
||||
panicking: m.ai.panicking,
|
||||
active_raid: raider::has_active_raid(m),
|
||||
opened_chest: m.extra.opened_chest,
|
||||
passenger: m.extra.mount.is_some(),
|
||||
vehicle: m.extra.mount,
|
||||
pushable: m.pushable(),
|
||||
sleeping: villager::sleeping(m)
|
||||
|| (m.species() == Species::Fox && fox::flag(m, fox::SLEEPING)),
|
||||
cold_strider: strider::suffocating(m),
|
||||
category: m.category(),
|
||||
potential_job: villager_brain::potential_job_site(m),
|
||||
meeting: villager.then_some(m.extra.meeting).flatten(),
|
||||
profession: m.extra.variant2,
|
||||
xp: villager::xp(m),
|
||||
villager: villager.then(|| Arc::new(villager::seen(m))),
|
||||
stats: [
|
||||
f64::from(m.attrs.max_health),
|
||||
m.extra.vector[0],
|
||||
m.attrs.speed,
|
||||
],
|
||||
sitting: m.extra.sitting_pose,
|
||||
silent: m.silent,
|
||||
lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one),
|
||||
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
|
||||
is_vehicle: equine::is_vehicle(m),
|
||||
still: m.species() == Species::HappyGhast && happy_ghast::on_still_timeout(m),
|
||||
carrying: m.rider.is_some() || m.extra.rider.is_some(),
|
||||
knockback_resistance: m.attrs.knockback_resistance,
|
||||
effects: m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| e.0.trim_start_matches("minecraft:"))
|
||||
.collect(),
|
||||
hunted_recently: m.species() == Species::Piglin && m.extra.ints[0] > 0,
|
||||
hurt_recently: m.species() == Species::Piglin && m.extra.piglin.hurt,
|
||||
huntable: m.species() == Species::Hoglin && !m.is_baby() && !m.extra.cannot_be_hunted,
|
||||
}
|
||||
}
|
||||
/// `EntityType.getCategory`'s name.
|
||||
pub fn category(&self) -> &'static str {
|
||||
match self.kind.category.as_str() {
|
||||
@@ -3630,7 +3340,8 @@ impl Mob {
|
||||
/// `LivingEntity.isInvulnerableTo` and dead or removed: only #bypasses_invulnerability gets
|
||||
/// past the flag. The guard every damage hook runs after (`LivingIncomingDamageEvent`).
|
||||
pub fn refuses_damage(&self, source: &Damage) -> bool {
|
||||
!self.alive()
|
||||
self.extra.client_side
|
||||
|| !self.alive()
|
||||
|| self.removed
|
||||
|| (self.is_invulnerable() && !source.kind.bypasses_invulnerability())
|
||||
}
|
||||
@@ -5816,6 +5527,26 @@ mod armor_hurt_tests {
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// `Entity.hurt` on a client level does nothing: the horse a rider's
|
||||
/// client drives takes no fall of its own, and the server's health stays
|
||||
/// (#15294: the client's copy died and lay on the ground, still ridable).
|
||||
#[test]
|
||||
fn a_client_copy_takes_no_fall_damage() {
|
||||
let mut m = horse();
|
||||
m.extra.client_side = true;
|
||||
let health = m.health;
|
||||
let mut events = Vec::new();
|
||||
assert!(!m.damage(20.0, Damage::of(DamageKind::Fall)));
|
||||
m.cause_fall_damage_at(30.0, 1.0, &mut events, None, DamageKind::Fall);
|
||||
assert_eq!(m.health, health);
|
||||
assert_eq!(m.hurt_time, 0);
|
||||
assert!(m.alive());
|
||||
// The server's copy of the same fall is hurt.
|
||||
let mut server = horse();
|
||||
server.cause_fall_damage_at(30.0, 1.0, &mut events, None, DamageKind::Fall);
|
||||
assert!(server.health < health);
|
||||
}
|
||||
|
||||
/// `LivingEntity.hurtArmor` (`doHurtEquipment`): a hit that lands on a mob
|
||||
/// wearing body armour leaves the piece owing a wear, which the host
|
||||
/// applies with the item's own maximum and fire rules.
|
||||
|
||||
@@ -33,7 +33,9 @@ mod wet_fire_tests;
|
||||
|
||||
mod air;
|
||||
mod step;
|
||||
mod walk_target;
|
||||
pub(crate) use step::step_sound;
|
||||
pub use walk_target::walk_target_value;
|
||||
|
||||
use crate::powder_snow::particle_velocity as powder_snow_particle_velocity;
|
||||
|
||||
@@ -1261,7 +1263,7 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
}
|
||||
}
|
||||
// Entity.checkBelowWorld: LivingEntity.onBelowWorld hurts 4 a tick.
|
||||
if m.position.y < f64::from(w.world.bottom_y() - 64) {
|
||||
if m.position.y < w.world.below_world_y() {
|
||||
hurt(m, w, events, 4.0, Damage::of(DamageKind::OutOfWorld));
|
||||
}
|
||||
m.first_tick = false;
|
||||
@@ -2551,81 +2553,6 @@ pub fn sound_volume(m: &Mob) -> f32 {
|
||||
}
|
||||
}
|
||||
/// PathfinderMob.getWalkTargetValue (Animal and Monster overrides).
|
||||
pub fn walk_target_value(m: &Mob, w: &MobWorld, p: [i32; 3]) -> f32 {
|
||||
let light = || w.brightness(p[0], p[1], p[2]) - 0.5;
|
||||
// Guardian.getWalkTargetValue: water first.
|
||||
if family(m.species()) == Family::Guardian && w.fluid(p[0], p[1], p[2]) == Fluid::Water {
|
||||
return 10.0 + light();
|
||||
}
|
||||
// HappyGhast.getWalkTargetValue: open air, best just above the ground.
|
||||
if m.species() == Species::HappyGhast {
|
||||
let air = |y: i32| w.facts.is_air(w.world.block(p[0], y, p[2]));
|
||||
return if !air(p[1]) {
|
||||
0.0
|
||||
} else if air(p[1] - 1) && !air(p[1] - 2) {
|
||||
10.0
|
||||
} else {
|
||||
5.0
|
||||
};
|
||||
}
|
||||
if m.species() == Species::Strider {
|
||||
return super::strider::walk_target_value(m, w, p);
|
||||
}
|
||||
if m.species() == Species::Turtle {
|
||||
return super::turtle::walk_target_value(m, w, p, light());
|
||||
}
|
||||
// Hoglin.getWalkTargetValue: nowhere near a repellent, crimson nylium best.
|
||||
if m.species() == Species::Hoglin {
|
||||
let near = m.extra.repellent.is_some_and(|r| {
|
||||
let d = |i: usize| i64::from(r[i] - p[i]);
|
||||
d(0) * d(0) + d(1) * d(1) + d(2) * d(2) < 64
|
||||
});
|
||||
return if near {
|
||||
-1.0
|
||||
} else if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:crimson_nylium" {
|
||||
10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
// Bee.getWalkTargetValue: any air block.
|
||||
if m.species() == Species::Bee {
|
||||
return if w.facts.is_air(w.world.block(p[0], p[1], p[2])) {
|
||||
10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
// AbstractNautilus, Axolotl and Creaking.getWalkTargetValue: everywhere alike.
|
||||
if matches!(
|
||||
m.species(),
|
||||
Species::Nautilus | Species::ZombieNautilus | Species::Axolotl | Species::Creaking
|
||||
) {
|
||||
return 0.0;
|
||||
}
|
||||
// Silverfish.getWalkTargetValue: a block sitting on an infested host is
|
||||
// the best place to walk to, so it goes back into the stone.
|
||||
if m.species() == Species::Silverfish
|
||||
&& super::hostile::is_infested_host(w.block_name(p[0], p[1] - 1, p[2]))
|
||||
{
|
||||
return 10.0;
|
||||
}
|
||||
if m.hostile()
|
||||
&& !matches!(
|
||||
m.species(),
|
||||
Species::Slime | Species::MagmaCube | Species::Ghast | Species::Phantom
|
||||
)
|
||||
{
|
||||
return -light();
|
||||
}
|
||||
if m.kind.flags.animal {
|
||||
if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:grass_block" {
|
||||
return 10.0;
|
||||
}
|
||||
return light();
|
||||
}
|
||||
0.0
|
||||
}
|
||||
/// `Allay.hurtServer`'s liked-player guard for verified player sources.
|
||||
/// Other species and unresolved callers retain their existing filters.
|
||||
pub fn accepts_damage_from_player(
|
||||
|
||||
@@ -5,10 +5,10 @@ use super::{Mob, Species, can_age_up};
|
||||
/// that `canFallInLove` (not in love yet) or a baby that `canAgeUp` (not age
|
||||
/// locked). Tamed wolves and cats only sit or stand when it does not.
|
||||
pub fn takes_food(m: &Mob) -> bool {
|
||||
// `Turtle.canFallInLove` is `super.canFallInLove() && !this.hasEgg()`: a
|
||||
// turtle that already carries an egg refuses its food instead of breeding.
|
||||
// `canFallInLove`: a turtle carrying an egg and a pacified hoglin refuse their food.
|
||||
let breedable = m.extra.in_love <= 0
|
||||
&& !(m.species() == Species::Turtle && crate::mobs::turtle::has_egg(m));
|
||||
&& !(m.species() == Species::Turtle && crate::mobs::turtle::has_egg(m))
|
||||
&& !crate::mobs::piglin::refuses_love(m);
|
||||
// The growing branch is `canAgeUp()`, which asks `isBaby()`, not the raw
|
||||
// age: a frog or a camel husk that `canBeABaby` says is never a baby keeps
|
||||
// the food in the player's hand however negative its `Age` is.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! `PathfinderMob.getWalkTargetValue` and the classes that override it: the score
|
||||
//! a stroll gives a candidate position, of which it keeps the best of ten.
|
||||
use super::{Family, Mob, MobWorld, Species, family};
|
||||
use crate::blocks::Fluid;
|
||||
|
||||
pub fn walk_target_value(m: &Mob, w: &MobWorld, p: [i32; 3]) -> f32 {
|
||||
let light = || w.brightness(p[0], p[1], p[2]) - 0.5;
|
||||
// Guardian.getWalkTargetValue: water first.
|
||||
if family(m.species()) == Family::Guardian && w.fluid(p[0], p[1], p[2]) == Fluid::Water {
|
||||
return 10.0 + light();
|
||||
}
|
||||
// HappyGhast.getWalkTargetValue: open air, best just above the ground.
|
||||
if m.species() == Species::HappyGhast {
|
||||
let air = |y: i32| w.facts.is_air(w.world.block(p[0], y, p[2]));
|
||||
return if !air(p[1]) {
|
||||
0.0
|
||||
} else if air(p[1] - 1) && !air(p[1] - 2) {
|
||||
10.0
|
||||
} else {
|
||||
5.0
|
||||
};
|
||||
}
|
||||
if m.species() == Species::Strider {
|
||||
return crate::mobs::strider::walk_target_value(m, w, p);
|
||||
}
|
||||
if m.species() == Species::Turtle {
|
||||
return crate::mobs::turtle::walk_target_value(m, w, p, light());
|
||||
}
|
||||
// Hoglin.getWalkTargetValue: nowhere near a repellent, crimson nylium best.
|
||||
if m.species() == Species::Hoglin {
|
||||
let near = m.extra.repellent.is_some_and(|r| {
|
||||
let d = |i: usize| i64::from(r[i] - p[i]);
|
||||
d(0) * d(0) + d(1) * d(1) + d(2) * d(2) < 64
|
||||
});
|
||||
return if near {
|
||||
-1.0
|
||||
} else if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:crimson_nylium" {
|
||||
10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
// Bee.getWalkTargetValue: any air block.
|
||||
if m.species() == Species::Bee {
|
||||
return if w.facts.is_air(w.world.block(p[0], p[1], p[2])) {
|
||||
10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
// AbstractNautilus, Axolotl and Creaking.getWalkTargetValue: everywhere alike.
|
||||
if matches!(
|
||||
m.species(),
|
||||
Species::Nautilus | Species::ZombieNautilus | Species::Axolotl | Species::Creaking
|
||||
) {
|
||||
return 0.0;
|
||||
}
|
||||
// Silverfish.getWalkTargetValue: a block sitting on an infested host is
|
||||
// the best place to walk to, so it goes back into the stone.
|
||||
if m.species() == Species::Silverfish
|
||||
&& crate::mobs::hostile::is_infested_host(w.block_name(p[0], p[1] - 1, p[2]))
|
||||
{
|
||||
return 10.0;
|
||||
}
|
||||
// Enderman.getWalkTargetValue, Pillager's and Warden's: every position scores
|
||||
// the same, so their strolls pick one of the ten at random.
|
||||
if matches!(
|
||||
m.species(),
|
||||
Species::Enderman | Species::Pillager | Species::Warden
|
||||
) {
|
||||
return 0.0;
|
||||
}
|
||||
if m.hostile()
|
||||
&& !matches!(
|
||||
m.species(),
|
||||
Species::Slime | Species::MagmaCube | Species::Ghast | Species::Phantom
|
||||
)
|
||||
{
|
||||
return -light();
|
||||
}
|
||||
// MushroomCow.getWalkTargetValue replaces Animal's: mycelium is the best
|
||||
// ground and any other is scored by the light, grass or not.
|
||||
if m.species() == Species::Mooshroom {
|
||||
return if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:mycelium" {
|
||||
10.0
|
||||
} else {
|
||||
w.brightness(p[0], p[1], p[2]) - 0.5
|
||||
};
|
||||
}
|
||||
if m.kind.flags.animal {
|
||||
if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:grass_block" {
|
||||
return 10.0;
|
||||
}
|
||||
return light();
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `PathfinderMob.getWalkTargetValue` and its overrides, at three positions
|
||||
/// that differ only in the block below. A cow scores grass 10 and mycelium by
|
||||
/// the light; a mushroom cow, whose own override replaces Animal's, mycelium
|
||||
/// 10 and grass by the light; a monster the negated light; and an enderman, a
|
||||
/// pillager and a warden the same 0 everywhere.
|
||||
#[test]
|
||||
fn a_stroll_scores_its_ground_the_way_the_jar_does() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|world, registry| {
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let grass = registry.default_state("minecraft:grass_block").unwrap();
|
||||
let mycelium = registry.default_state("minecraft:mycelium").unwrap();
|
||||
for cx in -1..1 {
|
||||
for cz in -1..1 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for x in -4..4 {
|
||||
for z in -4..4 {
|
||||
world.set(x, 63, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
world.set(-2, 63, 0, grass).unwrap();
|
||||
world.set(0, 63, 0, stone).unwrap();
|
||||
world.set(2, 63, 0, mycelium).unwrap();
|
||||
},
|
||||
|w, types| {
|
||||
let value = |id: &str, x: i32| {
|
||||
let kind = Arc::new(types.types[id].clone());
|
||||
let m = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
walk_target_value(&m, w, [x, 64, 0])
|
||||
};
|
||||
// The light term of the middle position, which the report quotes.
|
||||
let light = w.brightness(0, 64, 0) - 0.5;
|
||||
// A cow: grass 10, mycelium and stone by the light.
|
||||
assert_eq!(value("minecraft:cow", -2), 10.0, "cow on grass");
|
||||
assert_eq!(value("minecraft:cow", 0), light, "cow on stone");
|
||||
assert_eq!(value("minecraft:cow", 2), light, "cow on mycelium");
|
||||
// A mushroom cow: mycelium 10, grass by the light, not 10.
|
||||
assert_eq!(
|
||||
value("minecraft:mooshroom", 2),
|
||||
10.0,
|
||||
"mooshroom on mycelium"
|
||||
);
|
||||
assert_eq!(
|
||||
value("minecraft:mooshroom", -2),
|
||||
light,
|
||||
"mooshroom on grass"
|
||||
);
|
||||
// A monster: the negated light, on grass like anywhere else.
|
||||
assert_eq!(value("minecraft:zombie", -2), -light, "zombie");
|
||||
// The three that score every position the same.
|
||||
for flat in [
|
||||
"minecraft:enderman",
|
||||
"minecraft:pillager",
|
||||
"minecraft:warden",
|
||||
] {
|
||||
assert_eq!(value(flat, -2), 0.0, "{flat} on grass");
|
||||
assert_eq!(value(flat, 0), 0.0, "{flat} on stone");
|
||||
assert_eq!(value(flat, 2), 0.0, "{flat} on mycelium");
|
||||
}
|
||||
},
|
||||
);
|
||||
// Without the extracted vanilla data the world cannot be built.
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
@@ -2390,13 +2390,10 @@ impl Goal for FollowMob {
|
||||
let area = m
|
||||
.bounds()
|
||||
.inflate(Vec3::new(self.area, self.area, self.area));
|
||||
let found = cx
|
||||
.w
|
||||
.others_near(area)
|
||||
.find(|s| {
|
||||
s.species != species && s.bounds.intersects(area) && !s.has_effect("invisibility")
|
||||
})
|
||||
.map(|s| s.id);
|
||||
let found =
|
||||
cx.w.others_near(area)
|
||||
.find(|s| s.species != species && s.bounds.intersects(area) && !s.invisible)
|
||||
.map(|s| s.id);
|
||||
self.following = found;
|
||||
found.is_some()
|
||||
}
|
||||
|
||||
@@ -530,12 +530,16 @@ pub fn iron_anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
}
|
||||
|
||||
pub fn register_snow(m: &mut Mob) {
|
||||
// `new WaterAvoidingRandomStrollGoal(this, 1.0, 1.0000001E-5F)`: the probability of a plain
|
||||
// stroll is the constructor's third argument, not the default 0.001.
|
||||
let mut stroll = Stroll::avoiding_water(1.0);
|
||||
stroll.water_avoiding = Some(1.000_000_1e-5);
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(
|
||||
1,
|
||||
super::witch::RangedAttack::new(1.25, 20, 20, 10.0, snowball_attack),
|
||||
);
|
||||
g.add(2, Stroll::avoiding_water(1.0));
|
||||
g.add(2, stroll);
|
||||
g.add(3, LookAtPlayer::new(6.0));
|
||||
g.add(4, RandomLookAround::default());
|
||||
m.ai.targets.add(
|
||||
@@ -1134,3 +1138,34 @@ mod crack_sound_tests {
|
||||
assert_eq!(cracks(&mut m, 10.0), 1, "30 to 20 crosses 0.25");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod snow_golem_stroll_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A snow golem of a made-up type: only its goals matter here.
|
||||
fn snow_golem() -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
Arc::make_mut(&mut kind).id = "minecraft:snow_golem".into();
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// `SnowGolem.registerGoals` adds `new WaterAvoidingRandomStrollGoal(this, 1.0, 1.0000001E-5F)`
|
||||
/// (the bytecode's float constant): the chance of a plain stroll instead of a land one is
|
||||
/// that, not the two-argument constructor's default 0.001.
|
||||
#[test]
|
||||
fn a_snow_golem_strolls_with_the_rare_probability() {
|
||||
let mut m = snow_golem();
|
||||
// The made-up type brings goals of its own.
|
||||
m.ai.goals.clear();
|
||||
register_snow(&mut m);
|
||||
let goals = format!("{:?}", m.ai.goals);
|
||||
let rare = format!("water_avoiding: {:?}", Some(1.000_000_1e-5_f32));
|
||||
let default = format!("water_avoiding: {:?}", Some(0.001_f32));
|
||||
assert!(goals.contains(&rare), "{goals}");
|
||||
assert!(!goals.contains(&default), "{goals}");
|
||||
// One stroll goal, not two.
|
||||
assert_eq!(goals.matches("water_avoiding:").count(), 1, "{goals}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! `FollowMobGoal` skips an invisible mob: the flag or the invisibility effect.
|
||||
use super::goals::{Cx, FollowMob};
|
||||
use super::{Mob, Species};
|
||||
use crate::mobs::LegacyRandom;
|
||||
use crate::mobs::world::with_test_world;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `FollowMobGoal.canUse` skips an invisible mob (`!isInvisible()`), which
|
||||
/// is the flag or the invisibility effect.
|
||||
#[test]
|
||||
fn a_follow_mob_goal_ignores_an_invisible_mob() {
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
let mut outcomes = Vec::new();
|
||||
if !with_test_world(&[], |w, _| {
|
||||
for invisible in [false, true] {
|
||||
let mut follower_kind = (*kind()).clone();
|
||||
follower_kind.species = Species::Parrot;
|
||||
let mut target_kind = (*kind()).clone();
|
||||
target_kind.species = Species::Cow;
|
||||
let mut m = Mob::new(1, Arc::new(follower_kind), Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
let mut target = Mob::new(2, Arc::new(target_kind), Vec3::new(1.5, 64.0, 0.5), 0.0);
|
||||
target.invisible = invisible;
|
||||
let seen = [target.seen(None)];
|
||||
let world = crate::mobs::MobWorld {
|
||||
others: &seen,
|
||||
..w.clone()
|
||||
};
|
||||
m.ai.goals.add(1, FollowMob::new(1.0, 3.0, 7.0));
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Cx {
|
||||
w: &world,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
let mut goals = std::mem::take(&mut m.ai.goals);
|
||||
goals.tick(&mut m, &mut cx);
|
||||
outcomes.push(goals.is_running("FollowMobGoal"));
|
||||
m.ai.goals = goals;
|
||||
}
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
vec![true, false],
|
||||
"a visible mob is followed; an invisible one is not"
|
||||
);
|
||||
}
|
||||
@@ -100,6 +100,10 @@ pub struct Fluids {
|
||||
/// The mod fluid the mob is deepest in (`isInFluidType`): not water to the
|
||||
/// game, but the mob moves in it as in water, with its numbers.
|
||||
pub modded: Option<ModFluidImmersion>,
|
||||
/// The boat `positionRider` last seated the mob in, by id, and what
|
||||
/// `AbstractBoat.modifyPassengerFluidInteractionBox` reads of it. Kept
|
||||
/// across `update_fluids`; good while the mob still rides that boat.
|
||||
pub boat: Option<(u32, crate::motion::RiddenBoat)>,
|
||||
}
|
||||
/// `FluidFlowWorld` over the mob's scene: the neighbours a flow vector reads.
|
||||
struct FlowScene<'a, 'w>(&'a MobWorld<'w>);
|
||||
@@ -1061,11 +1065,36 @@ impl Mob {
|
||||
speed: f64::from(0.15_f32),
|
||||
});
|
||||
}
|
||||
/// `Entity.getFluidInteractionBox` and the fluids in it: the box less 0.001,
|
||||
/// then the ridden boat's say (`AbstractBoat.modifyPassengerFluidInteractionBox`).
|
||||
/// A boat that isn't underwater keeps only what lies above its top, and
|
||||
/// all of a passenger it reaches the head of, so a mob sitting in a boat
|
||||
/// on a lake is neither in the water (it burns in daylight, isn't pushed
|
||||
/// by the flow) nor splashes.
|
||||
fn fluid_interaction(&self, w: &MobWorld, boat: Option<crate::motion::RiddenBoat>) -> Fluids {
|
||||
let b = self.bounds();
|
||||
let Some(boat) = boat.filter(|boat| !boat.under_water) else {
|
||||
return w.fluids(b, self.eye_y());
|
||||
};
|
||||
// `fluids` deflates the box by 0.001 itself: the deflated top against
|
||||
// the boat's, and the deflated bottom raised to it, as vanilla does.
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
if boat.max_y >= hi.y - 0.001 {
|
||||
return Fluids::default();
|
||||
}
|
||||
let lo = Vec3::new(lo.x, lo.y.max(boat.max_y - 0.001), lo.z);
|
||||
w.fluids(Aabb::new(lo, hi), self.eye_y())
|
||||
}
|
||||
/// Entity.updateFluidInteraction: the fluid state, then touching water
|
||||
/// resets the fall and entering it (after the first tick) splashes.
|
||||
pub(super) fn update_fluids(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let was_touching_water = self.fluids.in_water;
|
||||
self.fluids = w.fluids(self.bounds(), self.eye_y());
|
||||
let boat = self
|
||||
.fluids
|
||||
.boat
|
||||
.filter(|(id, _)| self.extra.mount == Some(*id));
|
||||
self.fluids = self.fluid_interaction(w, boat.map(|(_, b)| b));
|
||||
self.fluids.boat = boat;
|
||||
// EntityFluidInteraction.applyCurrentTo: the flow pushes the mob.
|
||||
if (self.fluids.in_water || self.fluids.in_lava) && self.pushed_by_fluid() {
|
||||
self.velocity = w.pushed_by_currents(self.bounds(), self.velocity);
|
||||
|
||||
@@ -1179,6 +1179,64 @@ fn a_steered_mount_moves_through_the_air_at_a_tenth_of_its_speed() {
|
||||
assert_eq!(m.flying_speed(), 0.3_f32 * 0.1);
|
||||
}
|
||||
|
||||
/// `Entity.getFluidInteractionBox` with `AbstractBoat.modifyPassengerFluidInteractionBox`:
|
||||
/// a drowned sitting in a boat on a lake, its legs below the water's surface,
|
||||
/// isn't in the water (so it burns in daylight), while the same drowned
|
||||
/// without the boat, in a boat that sank, or with a boat it has since left, is.
|
||||
/// A boat reaching above its head keeps all fluids off it.
|
||||
#[test]
|
||||
fn a_mob_in_a_boat_on_water_is_not_in_the_water() {
|
||||
let build = |world: &mut crate::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry| {
|
||||
world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
for x in 0..=4 {
|
||||
for z in 0..=4 {
|
||||
// Sources up to y 3: the surface is at 3 + 8/9.
|
||||
for y in 1..=3 {
|
||||
world.set(x, y, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let boat = |under_water, max_y| crate::motion::RiddenBoat { under_water, max_y };
|
||||
crate::mobs::world::with_built_test_world(&[], build, |w, types| {
|
||||
let kind = std::sync::Arc::new(types.types["minecraft:drowned"].clone());
|
||||
let wet = |mount: Option<u32>, ridden: Option<crate::motion::RiddenBoat>| {
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(2.5, 3.5, 2.5), 0.0);
|
||||
m.extra.mount = mount;
|
||||
m.fluids.boat = ridden.map(|b| (7, b));
|
||||
m.update_fluids(w, &mut Vec::new());
|
||||
(m.fluids.in_water, m.fluids.water)
|
||||
};
|
||||
assert!(
|
||||
wet(None, None).0,
|
||||
"its legs are in the water without a boat"
|
||||
);
|
||||
assert_eq!(
|
||||
wet(Some(7), Some(boat(false, 3.95))),
|
||||
(false, 0.0),
|
||||
"a boat afloat keeps only what is above its top, clear of the water"
|
||||
);
|
||||
assert_eq!(
|
||||
wet(Some(7), Some(boat(false, 6.0))),
|
||||
(false, 0.0),
|
||||
"a boat reaching above its head keeps all of it out"
|
||||
);
|
||||
assert!(
|
||||
wet(Some(7), Some(boat(true, 3.95))).0,
|
||||
"a boat under water shelters nothing"
|
||||
);
|
||||
assert!(
|
||||
wet(None, Some(boat(false, 3.95))).0,
|
||||
"a boat it no longer rides shelters nothing"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Strider.checkFallDamage`: in lava the fall is reset before the base runs, so
|
||||
/// a strider that drops into a lava sea lands with no damage and no dust — and
|
||||
/// walks out owing nothing — while a pig in the same lava takes the fall, and a
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! A mob's baked model: the parts of `entity-models.json` as polygons, and the normal stack that packs their
|
||||
//! normals the way `ModelPart` does.
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Polygon {
|
||||
pub normal: [f32; 3],
|
||||
pub vertices: [[f32; 5]; 4],
|
||||
/// The model's render type culls back faces (`entityCutoutCull`: the bat,
|
||||
/// the baby turtle).
|
||||
pub cull_back: bool,
|
||||
}
|
||||
impl Polygon {
|
||||
pub(super) fn vertex_normal(&self, pose: ModelNormals) -> [i8; 4] {
|
||||
let n = pose.matrix * glam::Vec3::from_array(self.normal);
|
||||
let n = if pose.trusted {
|
||||
n
|
||||
} else {
|
||||
n.normalize_or_zero()
|
||||
};
|
||||
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
let w = if self.cull_back {
|
||||
crate::mesher::SHADED_CULL_W
|
||||
} else {
|
||||
-127
|
||||
};
|
||||
[packed[0], packed[1], packed[2], w]
|
||||
}
|
||||
}
|
||||
|
||||
/// ModelPart uses a separate normal stack: uniform scaling keeps normal lengths,
|
||||
/// while nonuniform scaling requires normalization after the complete part transform.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ModelNormals {
|
||||
pub(super) matrix: glam::Mat3,
|
||||
pub(super) trusted: bool,
|
||||
}
|
||||
impl ModelNormals {
|
||||
// shortcut: infer the root normal pose from its geometry matrix; pass an explicit
|
||||
// root normal stack when root-transform rounding is ported as well.
|
||||
pub(super) fn new(base: glam::Mat4) -> Self {
|
||||
let m = glam::Mat3::from_mat4(base);
|
||||
let lengths = glam::Vec3::new(m.x_axis.length(), m.y_axis.length(), m.z_axis.length());
|
||||
let uniform =
|
||||
lengths.min_element() > 0.0 && lengths.max_element() - lengths.min_element() < 1.0e-6;
|
||||
Self {
|
||||
matrix: if uniform {
|
||||
m / lengths.x
|
||||
} else {
|
||||
m.inverse().transpose()
|
||||
},
|
||||
trusted: uniform,
|
||||
}
|
||||
}
|
||||
pub(super) fn rotation(x: f32, y: f32, z: f32) -> glam::Mat3 {
|
||||
// JOML derives cosine from the rounded sine, then retains quaternion length.
|
||||
// Assuming an exactly unit quaternion changes a packed normal by one byte.
|
||||
let sin_cos = |angle: f32| {
|
||||
let angle = angle * 0.5;
|
||||
let sin = f64::from(angle).sin() as f32;
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let a = angle + std::f32::consts::FRAC_PI_2;
|
||||
let tau = std::f32::consts::TAU;
|
||||
let mut quadrant = a - (a / tau) as i32 as f32 * tau;
|
||||
if quadrant < 0.0 {
|
||||
quadrant += tau;
|
||||
}
|
||||
(
|
||||
sin,
|
||||
if quadrant >= std::f32::consts::PI {
|
||||
-cos
|
||||
} else {
|
||||
cos
|
||||
},
|
||||
)
|
||||
};
|
||||
let (sx, cx) = sin_cos(x);
|
||||
let (sy, cy) = sin_cos(y);
|
||||
let (sz, cz) = sin_cos(z);
|
||||
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
|
||||
let (w, x, y, z) = (
|
||||
cx * cycz + sx * sysz,
|
||||
sx * cycz - cx * sysz,
|
||||
cx * sycz + sx * cysz,
|
||||
cx * cysz - sx * sycz,
|
||||
);
|
||||
let (ww, xx, yy, zz) = (w * w, x * x, y * y, z * z);
|
||||
let (xy, xz, xw, yz, yw, zw) = (x * y, x * z, x * w, y * z, y * w, z * w);
|
||||
glam::Mat3::from_cols_array(&[
|
||||
ww + xx - zz - yy,
|
||||
(xy + xy) + (zw + zw),
|
||||
(xz + xz) - (yw + yw),
|
||||
(xy + xy) - (zw + zw),
|
||||
yy - zz + ww - xx,
|
||||
(yz + yz) + (xw + xw),
|
||||
(yw + yw) + (xz + xz),
|
||||
(yz + yz) - (xw + xw),
|
||||
zz - yy - xx + ww,
|
||||
])
|
||||
}
|
||||
pub(super) fn part(mut self, pose: [f32; 9]) -> Self {
|
||||
self.matrix *= Self::rotation(pose[3], pose[4], pose[5]);
|
||||
let scale = glam::Vec3::new(pose[6], pose[7], pose[8]);
|
||||
if scale.x.abs() == scale.y.abs() && scale.x.abs() == scale.z.abs() {
|
||||
if scale.min_element() < 0.0 {
|
||||
self.matrix *= glam::Mat3::from_diagonal(scale.signum());
|
||||
}
|
||||
} else {
|
||||
self.matrix *= glam::Mat3::from_diagonal(scale.recip());
|
||||
self.trusted = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
pub(super) fn part_rotated(mut self, pose: [f32; 9], rotation: glam::Mat3) -> Self {
|
||||
self.matrix *= rotation;
|
||||
let scale = glam::Vec3::new(pose[6], pose[7], pose[8]);
|
||||
if scale.x.abs() == scale.y.abs() && scale.x.abs() == scale.z.abs() {
|
||||
if scale.min_element() < 0.0 {
|
||||
self.matrix *= glam::Mat3::from_diagonal(scale.signum());
|
||||
}
|
||||
} else {
|
||||
self.matrix *= glam::Mat3::from_diagonal(scale.recip());
|
||||
self.trusted = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Part {
|
||||
pub name: String,
|
||||
/// x, y, z, xRot, yRot, zRot, xScale, yScale, zScale
|
||||
pub pose: [f32; 9],
|
||||
pub polygons: Vec<Polygon>,
|
||||
pub children: Vec<Part>,
|
||||
}
|
||||
impl Part {
|
||||
/// Marks every polygon as drawn by a back-face-culling render type.
|
||||
pub(super) fn set_cull_back(&mut self) {
|
||||
for poly in &mut self.polygons {
|
||||
poly.cull_back = true;
|
||||
}
|
||||
for child in &mut self.children {
|
||||
child.set_cull_back();
|
||||
}
|
||||
}
|
||||
pub(super) fn parse(name: &str, v: &Value) -> Self {
|
||||
let pose: Vec<f32> = v["pose"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
|
||||
.collect();
|
||||
let mut p = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
|
||||
for (i, x) in pose.iter().enumerate().take(9) {
|
||||
p[i] = *x;
|
||||
}
|
||||
let polygons: Vec<Polygon> = v["polygons"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(index, poly)| {
|
||||
let verts: Vec<[f32; 5]> = poly
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|vx| {
|
||||
let a: Vec<f32> = vx
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
|
||||
.collect();
|
||||
[a[0], a[1], a[2], a[3], a[4]]
|
||||
})
|
||||
.collect();
|
||||
(verts.len() == 4).then(|| Polygon {
|
||||
normal: v["normals"][index].as_array().map_or_else(
|
||||
|| {
|
||||
// shortcut: collapsed legacy faces have no recoverable normal; re-prepare resources.
|
||||
let p = verts
|
||||
.iter()
|
||||
.map(|v| glam::Vec3::new(v[0], v[1], v[2]))
|
||||
.collect::<Vec<_>>();
|
||||
(p[1] - p[0])
|
||||
.cross(p[2] - p[0])
|
||||
.normalize_or_zero()
|
||||
.to_array()
|
||||
},
|
||||
|n| std::array::from_fn(|i| n[i].as_f64().unwrap_or(0.0) as f32),
|
||||
),
|
||||
vertices: [verts[0], verts[1], verts[2], verts[3]],
|
||||
cull_back: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut children: Vec<Part> = v["children"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| Part::parse(k, c))
|
||||
.collect();
|
||||
children.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
// A model whose root holds a part also named "root" (allay, vex,
|
||||
// frog...): that inner part is what `root.getChild("root")` and the
|
||||
// keyframe bones mean, so the outer one gives up the name.
|
||||
let name = if name == "root" && children.iter().any(|c| c.name == "root") {
|
||||
""
|
||||
} else {
|
||||
name
|
||||
};
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
pose: p,
|
||||
polygons,
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1516,7 +1516,7 @@ pub fn register_hoglin(m: &mut Mob) {
|
||||
m.ai.targets.add(
|
||||
2,
|
||||
NearestTarget::player(true).when(|m, _| {
|
||||
!m.is_baby() && m.extra.ints[0] <= 0 && m.extra.piglin.activity != Activity::Avoid
|
||||
!m.is_baby() && !is_pacified(m) && m.extra.piglin.activity != Activity::Avoid
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1787,6 +1787,19 @@ fn find_hoglin_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> {
|
||||
];
|
||||
w.nearest_block(at, 8, 4, |_, name| tag.contains(name))
|
||||
}
|
||||
/// `HoglinAi.isPacified`: the `PACIFIED` memory (`ints[0]`) is present.
|
||||
/// `BecomePassiveIfMemoryPresent` sets it for 200 ticks while a
|
||||
/// `#hoglin_repellents` block is in `NEAREST_REPELLENT`.
|
||||
pub fn is_pacified(m: &Mob) -> bool {
|
||||
m.extra.ints[0] > 0
|
||||
}
|
||||
/// `Hoglin.canFallInLove` is `!HoglinAi.isPacified(this) && super.canFallInLove()`:
|
||||
/// a warped fungus nearby pacifies the adult, and it refuses the fungus in the
|
||||
/// player's hand. `ai::takes_food`'s baby branch asks `canAgeUp`, not
|
||||
/// `canFallInLove`, so a pacified baby still grows.
|
||||
pub fn refuses_love(m: &Mob) -> bool {
|
||||
m.species() == Species::Hoglin && is_pacified(m)
|
||||
}
|
||||
/// The hoglin's repellent memories: the sensors (every 20 ticks), the end of
|
||||
/// `PACIFIED` (`ints[0]`) and `BecomePassiveIfMemoryPresent(NEAREST_REPELLENT, 200)`.
|
||||
fn hoglin_repellent(m: &mut Mob, w: &MobWorld) {
|
||||
@@ -1798,9 +1811,7 @@ fn hoglin_repellent(m: &mut Mob, w: &MobWorld) {
|
||||
hoglin_sense(m, w);
|
||||
}
|
||||
// `BecomePassiveIfMemoryPresent` is in the idle and fight activities.
|
||||
if m.extra.ints[0] <= 0
|
||||
&& m.extra.repellent.is_some()
|
||||
&& m.extra.piglin.activity != Activity::Avoid
|
||||
if !is_pacified(m) && m.extra.repellent.is_some() && m.extra.piglin.activity != Activity::Avoid
|
||||
{
|
||||
m.extra.ints[0] = 200;
|
||||
m.ai.target = None;
|
||||
@@ -2773,3 +2784,145 @@ mod brute_home_tests {
|
||||
assert_eq!(piglin.extra.off_hand.as_deref(), Some("minecraft:shield"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hoglin_pacified_feeding_tests {
|
||||
use super::*;
|
||||
use crate::mobs::ai::{feed_animal, takes_food};
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use crate::mobs::world::with_built_test_world;
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stone floor under the origin and, at `(3, 0, 0)`, a warped fungus
|
||||
/// (one of `#hoglin_repellents`) while `repellent` is set.
|
||||
fn build(repellent: bool) -> impl FnOnce(&mut crate::world::World, &BlockRegistry) {
|
||||
move |world, registry| {
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
for cx in -4..4 {
|
||||
for cz in -4..4 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for x in -64..64 {
|
||||
for z in -64..64 {
|
||||
world.set(x, -1, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
if repellent {
|
||||
let fungus = registry.default_state("minecraft:warped_fungus").unwrap();
|
||||
world.set(3, 0, 0, fungus).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A hoglin of `age` on the floor at the origin.
|
||||
fn hoglin(age: i32) -> Mob {
|
||||
let mut kind = kind();
|
||||
Arc::make_mut(&mut kind).species = Species::Hoglin;
|
||||
let mut m = Mob::new(20, kind, Vec3::new(0.5, 0.0, 0.5), 0.0);
|
||||
m.extra.age = age;
|
||||
m
|
||||
}
|
||||
/// `HoglinAi.isPacified`: the `PACIFIED` memory, which the hoglin keeps in
|
||||
/// `extra.ints[0]`.
|
||||
fn pacified(m: &Mob) -> bool {
|
||||
m.extra.ints[0] > 0
|
||||
}
|
||||
/// Ticks the hoglin's per-tick piglin state `n` times (`ai_step` calls
|
||||
/// `hoglin_repellent` for a live hoglin): the sensor every 20 ticks, the
|
||||
/// `PACIFIED` countdown every tick. Twenty ticks always hold one sensor
|
||||
/// tick.
|
||||
fn tick(m: &mut Mob, w: &MobWorld, n: usize) {
|
||||
for _ in 0..n {
|
||||
m.age += 1;
|
||||
hoglin_repellent(m, w);
|
||||
}
|
||||
}
|
||||
/// Without any world (CI has no vanilla data): the `PACIFIED` memory alone
|
||||
/// makes an adult refuse its food, and a baby still grows.
|
||||
#[test]
|
||||
fn the_pacified_memory_alone_refuses_an_adult_and_lets_a_baby_grow() {
|
||||
let mut level = LegacyRandom::new(0);
|
||||
let mut adult = hoglin(0);
|
||||
assert!(takes_food(&adult), "an unpacified adult breeds");
|
||||
adult.extra.ints[0] = 200;
|
||||
assert!(!takes_food(&adult), "a pacified adult refuses its food");
|
||||
assert!(feed_animal(&mut adult, &mut level, None).is_none());
|
||||
assert_eq!(adult.extra.in_love, 0);
|
||||
|
||||
let mut baby = hoglin(-24000);
|
||||
baby.extra.ints[0] = 200;
|
||||
assert!(feed_animal(&mut baby, &mut level, None).is_some());
|
||||
assert_eq!(baby.extra.age, -24000 + 120 * 20, "a tenth of the growth");
|
||||
}
|
||||
/// `Hoglin.canFallInLove` is
|
||||
/// `!HoglinAi.isPacified(this) && super.canFallInLove()`, so
|
||||
/// `Animal.mobInteract` refuses the crimson fungus of an
|
||||
/// adult pacified by a nearby repellent: no food used, no love, no baby.
|
||||
/// The baby's branch asks `canAgeUp`, not `canFallInLove`, so a pacified
|
||||
/// baby still grows up, and no other species reads the hoglin's memory.
|
||||
#[test]
|
||||
fn a_pacified_hoglin_refuses_breeding_food_and_a_baby_still_grows() {
|
||||
let mut level = LegacyRandom::new(0);
|
||||
let ran = with_built_test_world(&[], build(true), |w, _| {
|
||||
let mut adult = hoglin(0);
|
||||
assert!(!pacified(&adult), "no repellent memory yet");
|
||||
assert!(takes_food(&adult), "an adult away from fungus breeds");
|
||||
tick(&mut adult, w, 20);
|
||||
assert_eq!(adult.extra.repellent, Some([3, 0, 0]), "the sensor sees it");
|
||||
assert!(pacified(&adult), "and the hoglin turns passive");
|
||||
assert!(!takes_food(&adult), "a pacified adult refuses its food");
|
||||
assert!(
|
||||
feed_animal(&mut adult, &mut level, None).is_none(),
|
||||
"so the fungus is not eaten"
|
||||
);
|
||||
assert_eq!(adult.extra.in_love, 0, "and it does not fall in love");
|
||||
|
||||
let mut baby = hoglin(-24000);
|
||||
tick(&mut baby, w, 20);
|
||||
assert!(pacified(&baby), "the baby is pacified too");
|
||||
assert!(takes_food(&baby), "but it still takes food to grow");
|
||||
assert!(feed_animal(&mut baby, &mut level, None).is_some());
|
||||
assert_eq!(baby.extra.age, -24000 + 120 * 20, "a tenth of the growth");
|
||||
assert_eq!(baby.extra.in_love, 0, "a baby never falls in love");
|
||||
|
||||
let mut cow = kind();
|
||||
Arc::make_mut(&mut cow).id = "minecraft:cow".into();
|
||||
let mut cow = Mob::new(21, cow, Vec3::new(0.5, 0.0, 0.5), 0.0);
|
||||
cow.extra.ints[0] = 200;
|
||||
assert!(takes_food(&cow), "another species ignores the memory");
|
||||
});
|
||||
let _ = ran;
|
||||
}
|
||||
/// `BecomePassiveIfMemoryPresent.create(NEAREST_REPELLENT, 200)` sets
|
||||
/// `PACIFIED` for 200 ticks while the sensor holds the repellent, and the
|
||||
/// sensor drops it as soon as no repellent is in reach: walking away from
|
||||
/// the fungus keeps the adult refusing food for the rest of the 200 ticks,
|
||||
/// then it breeds again (`HoglinAi.wasHurtBy` is the other way out).
|
||||
#[test]
|
||||
fn pacification_outlives_the_repellent_by_200_ticks() {
|
||||
let mut level = LegacyRandom::new(0);
|
||||
let ran = with_built_test_world(&[], build(true), |w, _| {
|
||||
let mut m = hoglin(0);
|
||||
tick(&mut m, w, 20);
|
||||
assert!(pacified(&m));
|
||||
// Out of `HoglinSpecificSensor`'s reach (8 sideways, 4 up or down).
|
||||
m.position = Vec3::new(40.5, 0.0, 0.5);
|
||||
tick(&mut m, w, 20);
|
||||
assert_eq!(m.extra.repellent, None, "the sensor lost the fungus");
|
||||
assert!(pacified(&m), "but PACIFIED still has 180 ticks left");
|
||||
assert!(!takes_food(&m));
|
||||
assert!(feed_animal(&mut m, &mut level, None).is_none());
|
||||
assert_eq!(m.extra.in_love, 0);
|
||||
|
||||
tick(&mut m, w, 180);
|
||||
assert!(!pacified(&m), "PACIFIED expires after 200 ticks");
|
||||
assert!(takes_food(&m), "the adult breeds again");
|
||||
assert!(feed_animal(&mut m, &mut level, None).is_some());
|
||||
assert_eq!(m.extra.in_love, 600);
|
||||
});
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `Mob::seen`: the per-tick snapshot goals read (`game` writes one per mob).
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Mob {
|
||||
/// What other mobs see of this one this tick (`_player` is unused: it keyed the villager
|
||||
/// reputation that nothing read). Inline: the game's snapshot of every mob writes each one in
|
||||
/// its slot, not through a copy.
|
||||
#[inline]
|
||||
pub fn seen(&self, _player: Option<[i32; 4]>) -> Seen {
|
||||
let m = self;
|
||||
let villager = m.species() == Species::Villager;
|
||||
Seen {
|
||||
in_water: self.fluids.in_water,
|
||||
playing_dead: axolotl::is_playing_dead(self),
|
||||
last_damage: self.last_damage.map(|(d, _)| d),
|
||||
id: m.id,
|
||||
species: m.species(),
|
||||
position: m.position,
|
||||
bounds: m.bounds(),
|
||||
hitbox: m.hitbox(),
|
||||
eye_y: m.eye_y(),
|
||||
alive: m.alive(),
|
||||
baby: m.is_baby(),
|
||||
// A villager is "in love" when it is willing to breed (`canBreed`).
|
||||
in_love: if villager {
|
||||
villager::can_breed(m)
|
||||
} else {
|
||||
m.extra.in_love > 0
|
||||
},
|
||||
tame: m.extra.tame,
|
||||
bred: m.extra.bred,
|
||||
owner: m.extra.owner,
|
||||
hostile: m.is_enemy(),
|
||||
target: m.ai.target,
|
||||
health: m.health,
|
||||
variant: m.extra.variant,
|
||||
size: m.extra.size,
|
||||
color: m.extra.color,
|
||||
job: villager.then_some(m.extra.home).flatten(),
|
||||
bed: villager.then_some(m.extra.bed).flatten(),
|
||||
yaw: m.yaw,
|
||||
velocity: m.velocity,
|
||||
known_speed: m.position.subtract(m.previous),
|
||||
leash: m.extra.leash,
|
||||
caravan: caravan::head_of(m),
|
||||
school: (m.extra.leader, m.extra.followers),
|
||||
section: (m.extra.section, m.extra.section_seq),
|
||||
delay: m.extra.ints[5],
|
||||
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
|
||||
invisible: m.invisible
|
||||
|| m.extra
|
||||
.effects
|
||||
.iter()
|
||||
.any(|e| e.0 == "minecraft:invisibility"),
|
||||
panicking: m.ai.panicking,
|
||||
active_raid: raider::has_active_raid(m),
|
||||
opened_chest: m.extra.opened_chest,
|
||||
passenger: m.extra.mount.is_some(),
|
||||
vehicle: m.extra.mount,
|
||||
pushable: m.pushable(),
|
||||
sleeping: villager::sleeping(m)
|
||||
|| (m.species() == Species::Fox && fox::flag(m, fox::SLEEPING)),
|
||||
cold_strider: strider::suffocating(m),
|
||||
category: m.category(),
|
||||
potential_job: villager_brain::potential_job_site(m),
|
||||
meeting: villager.then_some(m.extra.meeting).flatten(),
|
||||
profession: m.extra.variant2,
|
||||
xp: villager::xp(m),
|
||||
villager: villager.then(|| Arc::new(villager::seen(m))),
|
||||
stats: [
|
||||
f64::from(m.attrs.max_health),
|
||||
m.extra.vector[0],
|
||||
m.attrs.speed,
|
||||
],
|
||||
sitting: m.extra.sitting_pose,
|
||||
silent: m.silent,
|
||||
lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one),
|
||||
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
|
||||
is_vehicle: equine::is_vehicle(m),
|
||||
still: m.species() == Species::HappyGhast && happy_ghast::on_still_timeout(m),
|
||||
carrying: m.rider.is_some() || m.extra.rider.is_some(),
|
||||
knockback_resistance: m.attrs.knockback_resistance,
|
||||
effects: m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| e.0.trim_start_matches("minecraft:"))
|
||||
.collect(),
|
||||
hunted_recently: m.species() == Species::Piglin && m.extra.ints[0] > 0,
|
||||
hurt_recently: m.species() == Species::Piglin && m.extra.piglin.hurt,
|
||||
huntable: m.species() == Species::Hoglin && !m.is_baby() && !m.extra.cannot_be_hunted,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1033,8 +1033,12 @@ pub fn register_cat(m: &mut Mob) {
|
||||
// in insertion order, so it comes last.
|
||||
g.add(
|
||||
4,
|
||||
super::goals::AvoidEntity::new(super::goals::Avoid::Player, 16.0, 0.8, 1.33)
|
||||
.when(|m| !m.extra.tame),
|
||||
CatAvoid(super::goals::AvoidEntity::new(
|
||||
super::goals::Avoid::Player,
|
||||
16.0,
|
||||
0.8,
|
||||
1.33,
|
||||
)),
|
||||
);
|
||||
m.ai.targets.add(
|
||||
1,
|
||||
@@ -1239,6 +1243,30 @@ fn empty_above(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
pub fn is_owner_of(m: &Mob, p: &super::PlayerView) -> bool {
|
||||
m.extra.owner.is_none_or(|o| o == p.uuid)
|
||||
}
|
||||
/// `Cat.CatAvoidEntityGoal`: the player avoidance only while untamed.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatAvoid(super::goals::AvoidEntity);
|
||||
impl Goal for CatAvoid {
|
||||
goal_clone!();
|
||||
fn flags(&self) -> u8 {
|
||||
self.0.flags()
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
!m.extra.tame && self.0.can_use(m, cx)
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
!m.extra.tame && self.0.can_continue(m, cx)
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.0.start(m, cx)
|
||||
}
|
||||
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.0.stop(m, cx)
|
||||
}
|
||||
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.0.tick(m, cx)
|
||||
}
|
||||
}
|
||||
/// `Cat.CatRelaxOnOwnerGoal`: while the owner sleeps, the cat lies down beside
|
||||
/// them, and may leave a morning gift when they wake up.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -2301,6 +2329,66 @@ mod tests {
|
||||
"an alive attacker is avenged; a dead one is not"
|
||||
);
|
||||
}
|
||||
/// `Cat.CatAvoidEntityGoal.canContinueToUse` is `!cat.isTame()
|
||||
/// && super.canContinueToUse()`: taming a fleeing cat stops the flight.
|
||||
#[test]
|
||||
fn a_cat_stops_fleeing_when_it_is_tamed() {
|
||||
use crate::mobs::goals::Cx;
|
||||
use crate::mobs::path::{Path, PathNode, PathType};
|
||||
use crate::mobs::world::with_test_world;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::sync::Arc;
|
||||
let mut outcomes = Vec::new();
|
||||
if !with_test_world(&[], |w, _| {
|
||||
for tame in [false, true] {
|
||||
let mut kind = (*kind()).clone();
|
||||
kind.species = Species::Cat;
|
||||
let mut m = Mob::new(1, Arc::new(kind), Vec3::new(0.5, 65.0, 0.5), 0.0);
|
||||
m.extra.tame = tame;
|
||||
m.ai.nav.path = Some(Path {
|
||||
nodes: vec![
|
||||
PathNode {
|
||||
x: 0,
|
||||
y: 65,
|
||||
z: 0,
|
||||
kind: PathType::Walkable,
|
||||
},
|
||||
PathNode {
|
||||
x: 2,
|
||||
y: 65,
|
||||
z: 0,
|
||||
kind: PathType::Walkable,
|
||||
},
|
||||
],
|
||||
next: 0,
|
||||
target: [2, 65, 0],
|
||||
dist_to_target: 0.0,
|
||||
reached: true,
|
||||
});
|
||||
let mut goal = CatAvoid(super::super::goals::AvoidEntity::new(
|
||||
super::super::goals::Avoid::Player,
|
||||
16.0,
|
||||
0.8,
|
||||
1.33,
|
||||
));
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
outcomes.push(goal.can_continue(&mut m, &mut cx));
|
||||
}
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
vec![true, false],
|
||||
"an untamed cat flees; a tamed one stops"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NeutralMob.isAngryAtAllPlayers`: with the `universal_anger` game rule on, a
|
||||
|
||||
@@ -129,7 +129,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
/// take too) and suffocation (`isInWall`, which the caller decides from
|
||||
/// the blocks at the eyes).
|
||||
pub fn server_hazards_tick(&mut self, in_wall: bool) -> Result<()> {
|
||||
if self.player.position.y < f64::from(self.scene.bottom_y() - 64) {
|
||||
if self.player.position.y < self.scene.below_world_y() {
|
||||
let id = self
|
||||
.health
|
||||
.damage
|
||||
@@ -397,3 +397,67 @@ mod fire_ignition_tests {
|
||||
assert_eq!(ignite_contact(ticks, true, &mut r, 0.4), 64);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod below_world_tests {
|
||||
use super::*;
|
||||
use crate::chunk::LevelShape;
|
||||
|
||||
/// The player's health after one `server_hazards_tick` at `y` in a level of `shape`.
|
||||
fn health_after_hazards(shape: LevelShape, y: f64) -> f32 {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
scene.shape = shape;
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(0.5, y, 0.5)).unwrap();
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
health: &resources.health,
|
||||
}
|
||||
.server_hazards_tick(false)
|
||||
.unwrap();
|
||||
player.health.health()
|
||||
}
|
||||
|
||||
/// `Entity.checkBelowWorld` hurts below `Level.getMinY() - 64`: y = -64 in the End and the
|
||||
/// Nether (min Y 0), y = -128 in the Overworld, whatever the chunks' widened column holds.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_void_starts_64_blocks_below_each_levels_floor() {
|
||||
assert_eq!(
|
||||
health_after_hazards(LevelShape::END, -64.5),
|
||||
16.0,
|
||||
"End: below -64"
|
||||
);
|
||||
assert_eq!(
|
||||
health_after_hazards(LevelShape::NETHER, -64.5),
|
||||
16.0,
|
||||
"Nether: below -64"
|
||||
);
|
||||
assert_eq!(
|
||||
health_after_hazards(LevelShape::END, -63.5),
|
||||
20.0,
|
||||
"End: above -64"
|
||||
);
|
||||
assert_eq!(
|
||||
health_after_hazards(LevelShape::OVERWORLD, -64.5),
|
||||
20.0,
|
||||
"Overworld: above -128"
|
||||
);
|
||||
assert_eq!(
|
||||
health_after_hazards(LevelShape::OVERWORLD, -128.5),
|
||||
16.0,
|
||||
"Overworld: below -128"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1651,6 +1651,8 @@ impl Screen {
|
||||
},
|
||||
Some(50),
|
||||
) => {
|
||||
// Held through the save: a ping storing an icon meanwhile would undo the edit.
|
||||
let guard = servers::lock();
|
||||
let (mut list, mut hidden) = servers::load_all(&servers_file(saves));
|
||||
let mut entry = ServerEntry::new(name.trim(), address.trim());
|
||||
entry.accept_textures = *packs;
|
||||
@@ -1674,6 +1676,8 @@ impl Screen {
|
||||
},
|
||||
}
|
||||
let _ = servers::save_all(&servers_file(saves), &list, &hidden);
|
||||
// Before the list is shown again: loading it may save, which takes the lock.
|
||||
drop(guard);
|
||||
*self = Self::list(saves);
|
||||
}
|
||||
(
|
||||
|
||||
@@ -333,6 +333,11 @@ pub trait ProjectileWorld {
|
||||
velocity
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)];
|
||||
/// `Level.getMinY() - 64` (`Entity.checkBelowWorld`); the Overworld's
|
||||
/// unless the world says otherwise.
|
||||
fn below_world_y(&self) -> f64 {
|
||||
-128.0
|
||||
}
|
||||
/// Server-only mod-block contacts along the completed movement.
|
||||
fn block_contacts(&self, _entity: i32, _kind: &str, _from: Vec3, _to: Vec3, _bounds: Aabb) {}
|
||||
/// `Level.isEmptyBlock`.
|
||||
@@ -1223,7 +1228,7 @@ impl Projectile {
|
||||
}
|
||||
}
|
||||
}
|
||||
(self.position.y > -128.0, Hit::None)
|
||||
(self.position.y >= w.below_world_y(), Hit::None)
|
||||
}
|
||||
/// AbstractHurtingProjectile.applyInertia, on both server and client.
|
||||
pub fn apply_hurting_inertia(&mut self, water: bool) {
|
||||
@@ -1569,7 +1574,7 @@ impl Projectile {
|
||||
{
|
||||
self.position = at;
|
||||
self.touching_water = w.in_water(self.bounds());
|
||||
let alive = self.position.y > -128.0 && self.age < 1200;
|
||||
let alive = self.position.y >= w.below_world_y() && self.age < 1200;
|
||||
return (alive, Hit::None);
|
||||
}
|
||||
// LlamaSpit never discards on an entity: `onHitEntity` only hurts, so
|
||||
@@ -1617,7 +1622,10 @@ impl Projectile {
|
||||
self.velocity.y * drag - self.get_gravity(),
|
||||
self.velocity.z * drag,
|
||||
);
|
||||
return (self.position.y > -128.0 && self.age < 6000, spit_hit);
|
||||
return (
|
||||
self.position.y >= w.below_world_y() && self.age < 6000,
|
||||
spit_hit,
|
||||
);
|
||||
}
|
||||
self.position = to;
|
||||
// Fireballs ease towards the motion (`ProjectileUtil.rotateTowardsMovement`);
|
||||
@@ -1632,8 +1640,8 @@ impl Projectile {
|
||||
// it touches now, and their currents push it.
|
||||
self.touching_water = w.in_water(self.bounds());
|
||||
self.velocity = w.pushed_by_currents(self.bounds(), self.velocity);
|
||||
let alive =
|
||||
self.position.y > -128.0 && self.age < if self.is_fireball() { 1200 } else { 6000 };
|
||||
let alive = self.position.y >= w.below_world_y()
|
||||
&& self.age < if self.is_fireball() { 1200 } else { 6000 };
|
||||
(alive, Hit::None)
|
||||
}
|
||||
/// AbstractArrow.onHitEntity damage: ceil(speed * base) plus crit bonus.
|
||||
|
||||
@@ -504,6 +504,11 @@ fn run(open: &mut Open, ctx: &Ctx<'_>, only_changed: bool, trigger: Trigger) ->
|
||||
}
|
||||
|
||||
impl ScreenNarrator {
|
||||
/// `Minecraft.getLastInputType`: widgets also use it for focused tooltips.
|
||||
pub fn input_type(&self) -> InputType {
|
||||
self.input
|
||||
}
|
||||
|
||||
fn now(&self) -> i64 {
|
||||
self.clock
|
||||
.unwrap_or_else(|| self.epoch.elapsed().as_millis() as i64)
|
||||
|
||||
@@ -24,6 +24,7 @@ mod flat;
|
||||
mod onboarding;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod world_folder;
|
||||
pub mod world_management;
|
||||
pub use buffet::State as BuffetEditor;
|
||||
pub use create_world::{DIFFICULTIES, WorldOptions};
|
||||
@@ -1849,29 +1850,8 @@ impl Title {
|
||||
}
|
||||
/// The folder a new world named `name` gets (`findResultFolder`).
|
||||
fn target_folder(&self, name: &str) -> String {
|
||||
let base: String = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let base = if base.trim().is_empty() {
|
||||
"World".to_owned()
|
||||
} else {
|
||||
base.trim().to_owned()
|
||||
};
|
||||
// FileUtil.findAvailableName: append (n) until unused.
|
||||
let mut folder = base.clone();
|
||||
let mut n = 1;
|
||||
while self.saves.join(&folder).exists() {
|
||||
folder = format!("{base} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
folder
|
||||
let new_world = lang::get_or("selectWorld.newWorld", "New World");
|
||||
world_folder::result_folder(name, &new_world, |f| self.saves.join(f).exists())
|
||||
}
|
||||
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
|
||||
// The game rules draw and press their own.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! The folder a new world gets: `WorldCreationUiState.findResultFolder` through
|
||||
//! `FileUtil.findAvailableName`. Lengths are Java's, in UTF-16 units.
|
||||
|
||||
/// `SharedConstants.ILLEGAL_FILE_CHARACTERS`, in its order.
|
||||
const ILLEGAL_FILE_CHARACTERS: [char; 15] = [
|
||||
'/', '\n', '\r', '\t', '\0', '\u{c}', '`', '?', '*', '\\', '<', '>', '|', '"', ':',
|
||||
];
|
||||
|
||||
/// `FileUtil.MAX_FILE_NAME`.
|
||||
const MAX_FILE_NAME: usize = 255;
|
||||
|
||||
/// The names `FileUtil.RESERVED_WINDOWS_FILENAMES` refuses once `sanitizeName` has turned every
|
||||
/// dot into `_` (its `.*\.` and `(?:\..*)?` parts can no longer match).
|
||||
const RESERVED: [&str; 24] = [
|
||||
"COM", "CLOCK$", "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6",
|
||||
"COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
/// `findResultFolder(name)`: the name trimmed as `String.trim` does, `new_world` (the translated
|
||||
/// `selectWorld.newWorld`) when that leaves nothing, made free of the folders `taken` says exist.
|
||||
/// A name whose count can't be read (`Mine ()`) falls back to `World`, as the method's catch does.
|
||||
pub(super) fn result_folder(name: &str, new_world: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
let name = name.trim_matches(|c: char| c <= ' ');
|
||||
let name = if name.is_empty() { new_world } else { name };
|
||||
available_name(name, &taken)
|
||||
.or_else(|| available_name("World", &taken))
|
||||
.unwrap_or_else(|| "World".to_owned())
|
||||
}
|
||||
|
||||
/// `FileUtil.sanitizeName`.
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if ILLEGAL_FILE_CHARACTERS.contains(&c) || matches!(c, '.' | '/' | '"') {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `FileUtil.COPY_COUNTER_PATTERN`, `(?<name>.*) \((?<count>\d*)\)`, matched whole: the name and
|
||||
/// the digits. `.` matches no line terminator, `\d` only ASCII digits.
|
||||
fn copy_counter(name: &str) -> Option<(&str, &str)> {
|
||||
let inner = name.strip_suffix(')')?;
|
||||
let digits = inner.len() - inner.trim_end_matches(|c: char| c.is_ascii_digit()).len();
|
||||
let (rest, digits) = inner.split_at(inner.len() - digits);
|
||||
let base = rest.strip_suffix(" (")?;
|
||||
let line_end = |c: char| matches!(c, '\n' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}');
|
||||
(!base.contains(line_end)).then_some((base, digits))
|
||||
}
|
||||
|
||||
fn java_len(s: &str) -> usize {
|
||||
s.encode_utf16().count()
|
||||
}
|
||||
|
||||
/// `String.substring(0, n)` in UTF-16 units (a pair cut in two becomes U+FFFD).
|
||||
fn java_prefix(s: &str, n: usize) -> String {
|
||||
String::from_utf16_lossy(&s.encode_utf16().take(n).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// `FileUtil.findAvailableName(folder, name, "")`: None where Java's `Integer.parseInt` of the
|
||||
/// count throws.
|
||||
fn available_name(name: &str, taken: &impl Fn(&str) -> bool) -> Option<String> {
|
||||
let mut name = sanitize_name(name);
|
||||
if RESERVED.iter().any(|r| r.eq_ignore_ascii_case(&name)) {
|
||||
name = format!("_{name}_");
|
||||
}
|
||||
let mut count = 0i32;
|
||||
if let Some((base, digits)) = copy_counter(&name) {
|
||||
count = digits.parse().ok()?;
|
||||
name = base.to_owned();
|
||||
}
|
||||
if java_len(&name) > MAX_FILE_NAME {
|
||||
name = java_prefix(&name, MAX_FILE_NAME);
|
||||
}
|
||||
loop {
|
||||
let mut candidate = name.clone();
|
||||
if count != 0 {
|
||||
let counter = format!(" ({count})");
|
||||
let room = MAX_FILE_NAME - counter.len();
|
||||
if java_len(&candidate) > room {
|
||||
candidate = java_prefix(&candidate, room);
|
||||
}
|
||||
candidate.push_str(&counter);
|
||||
}
|
||||
if !taken(&candidate) {
|
||||
return Some(candidate);
|
||||
}
|
||||
count = count.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::result_folder;
|
||||
|
||||
fn folder(name: &str, taken: &[&str]) -> String {
|
||||
result_folder(name, "New World", |f| taken.contains(&f))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_worlds_folder_is_named_as_vanilla_names_it() {
|
||||
// A blank name takes the translated "New World", trimmed as String.trim does.
|
||||
assert_eq!(folder("", &[]), "New World");
|
||||
assert_eq!(folder(" \t ", &[]), "New World");
|
||||
assert_eq!(folder(" Base ", &[]), "Base");
|
||||
// sanitizeName: only ILLEGAL_FILE_CHARACTERS and . / " become _.
|
||||
assert_eq!(folder("My.World/Test", &[]), "My_World_Test");
|
||||
assert_eq!(folder("Bob's world! #1", &[]), "Bob's world! #1");
|
||||
assert_eq!(folder("a:b*c?d", &[]), "a_b_c_d");
|
||||
// A name Windows reserves is wrapped in underscores (isPathPartPortable).
|
||||
assert_eq!(folder("con", &[]), "_con_");
|
||||
assert_eq!(folder("LPT1", &[]), "_LPT1_");
|
||||
// A name already taken counts up from 1; a name that ends in a count goes on from it.
|
||||
assert_eq!(folder("Hello", &["Hello"]), "Hello (1)");
|
||||
assert_eq!(folder("Hello", &["Hello", "Hello (1)"]), "Hello (2)");
|
||||
assert_eq!(folder("World (3)", &[]), "World (3)");
|
||||
assert_eq!(folder("World (3)", &["World (3)"]), "World (4)");
|
||||
// An empty count can't be parsed: findResultFolder falls back to "World".
|
||||
assert_eq!(folder("Mine ()", &[]), "World");
|
||||
// At most 255 characters, the count included.
|
||||
let long = "a".repeat(300);
|
||||
assert_eq!(folder(&long, &[]), "a".repeat(255));
|
||||
let first = "a".repeat(255);
|
||||
assert_eq!(
|
||||
folder(&long, &[first.as_str()]),
|
||||
format!("{} (1)", "a".repeat(251))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,10 @@ pub fn join(config: &GuestConfig) -> Result<Tunnel, JoinError> {
|
||||
Parsed::Long(invite) => {
|
||||
if config.path != PathMode::RelayOnly
|
||||
&& !invite.endpoints.is_empty()
|
||||
&& let Some(t) = join_direct(&invite)?
|
||||
&& let Some(t) = join_direct(
|
||||
&invite,
|
||||
invite.relay.is_some() && config.path != PathMode::DirectOnly,
|
||||
)?
|
||||
{
|
||||
return Ok(t);
|
||||
}
|
||||
@@ -133,9 +136,12 @@ pub fn join(config: &GuestConfig) -> Result<Tunnel, JoinError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dial every endpoint of the code; the first that answers as a Horizon
|
||||
/// host gets the handshake, the others are dropped before it.
|
||||
fn join_direct(invite: &Invite) -> Result<Option<Tunnel>, JoinError> {
|
||||
/// Dial every endpoint of the code; each that answers as a Horizon host gets the handshake in
|
||||
/// turn until one takes the code. Another host can answer first (a housemate's game at the same
|
||||
/// private address on the default port), so a failed handshake moves on rather than ending the
|
||||
/// join. None when no endpoint took it and `fallback` (the relay) is still to be tried; else the
|
||||
/// last failure.
|
||||
fn join_direct(invite: &Invite, fallback: bool) -> Result<Option<Tunnel>, JoinError> {
|
||||
let (tx, rx) = channel();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut session = [0u8; 8];
|
||||
@@ -154,23 +160,33 @@ fn join_direct(invite: &Invite) -> Result<Option<Tunnel>, JoinError> {
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
let Ok(mut stream) = rx.recv_timeout(left) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let key = crypto::exchange(&mut stream, &invite.secret_text(), "direct", session)
|
||||
.map_err(|e| JoinError::Failed(e.to_string()))?;
|
||||
let secure = session::secure(stream, &key, Role::Guest).map_err(|e| {
|
||||
if crypto::is_wrong_code(&e) {
|
||||
JoinError::WrongCode
|
||||
} else {
|
||||
JoinError::Failed(e.to_string())
|
||||
let mut failure = None;
|
||||
while let Ok(mut stream) = rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
|
||||
let key = match crypto::exchange(&mut stream, &invite.secret_text(), "direct", session) {
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
failure.get_or_insert(JoinError::Failed(e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match session::secure(stream, &key, Role::Guest) {
|
||||
Ok(secure) => {
|
||||
return Ok(Some(Tunnel {
|
||||
secure,
|
||||
direct: true,
|
||||
}));
|
||||
}
|
||||
// A wrong code says more than any other failure: it is the one kept.
|
||||
Err(e) if crypto::is_wrong_code(&e) => failure = Some(JoinError::WrongCode),
|
||||
Err(e) => {
|
||||
failure.get_or_insert(JoinError::Failed(e.to_string()));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Some(Tunnel {
|
||||
secure,
|
||||
direct: true,
|
||||
}))
|
||||
}
|
||||
match failure {
|
||||
Some(failure) if !fallback => Err(failure),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Through a relay with its short code (its own direct race, then the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Blocks and items as the client knows them, below gameplay: block facts
|
||||
//! (`blocks`), item stacks and definitions (`items`), their data components
|
||||
//! (`item_data`), enchantments, cooldowns, bundles and item stacks in NBT.
|
||||
//! (`item_data`), mining speed (`mining`), enchantments, cooldowns, bundles and
|
||||
//! item stacks in NBT.
|
||||
//! horizon-client re-exports each module under its old path
|
||||
//! (`horizon_client::items`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -16,6 +17,7 @@ pub mod flowers;
|
||||
pub mod item_data;
|
||||
pub mod item_nbt;
|
||||
pub mod items;
|
||||
pub mod mining;
|
||||
pub mod random_sequences;
|
||||
pub mod recipe_filters;
|
||||
pub mod use_animation;
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user