mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
458
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ab2b6cc9 | ||
|
|
bf9ad251b5 | ||
|
|
9de19c3322 | ||
|
|
1ad1d68d86 | ||
|
|
e9a3d18090 | ||
|
|
67f18b252b | ||
|
|
de0314e88e | ||
|
|
46147f9f41 | ||
|
|
bb8b7bdd64 | ||
|
|
0011cb63db | ||
|
|
ddfe91fa18 | ||
|
|
0706f9ca44 | ||
|
|
83454a3bcb | ||
|
|
13e1b831a5 | ||
|
|
ff7bc961d5 | ||
|
|
a625c07232 | ||
|
|
28d41d7cc4 | ||
|
|
acba915498 | ||
|
|
1f4e3e611c | ||
|
|
05f48119ee | ||
|
|
d32f9f8451 | ||
|
|
9d134df84b | ||
|
|
aef77dfbea | ||
|
|
a5a33fe9ad | ||
|
|
c8f280c65e | ||
|
|
ce244b4e86 | ||
|
|
af5b887b94 | ||
|
|
7b16eb4729 | ||
|
|
547125972c | ||
|
|
f5be4c37cc | ||
|
|
25a55c23a2 | ||
|
|
35b2d274ec | ||
|
|
c868fd8774 | ||
|
|
28410379f8 | ||
|
|
c9567418a7 | ||
|
|
c4054278e6 | ||
|
|
a136178ff8 | ||
|
|
9f6d1d2f97 | ||
|
|
1d72029a0c | ||
|
|
ab7c7ad45f | ||
|
|
1f99864eb0 | ||
|
|
edcc980bc6 | ||
|
|
3e1896f5d2 | ||
|
|
341453a7e3 | ||
|
|
71be471fc3 | ||
|
|
5fc44858ee | ||
|
|
f609dc97d5 | ||
|
|
2cf3e00cd4 | ||
|
|
3e55b98477 | ||
|
|
d32a944e67 | ||
|
|
96beca1079 | ||
|
|
2e0bed63b3 | ||
|
|
a94596db72 | ||
|
|
7b55cec0a8 | ||
|
|
ce1aab0899 | ||
|
|
82fbb7a627 | ||
|
|
fc47c8a3a2 | ||
|
|
2286fdbc60 | ||
|
|
cc217871f1 | ||
|
|
ed23f2d936 | ||
|
|
204c02eee4 | ||
|
|
90aa64c8db | ||
|
|
10e04403b9 | ||
|
|
5410d116ec | ||
|
|
4ae05891a3 | ||
|
|
56638bb53b | ||
|
|
ab44332995 | ||
|
|
6c85d24c4d | ||
|
|
c7d7a8cb2a | ||
|
|
7d65bc7cc6 | ||
|
|
e3e6a8f394 | ||
|
|
a3a726ad94 | ||
|
|
d0246ac72f | ||
|
|
888309df58 | ||
|
|
678c3ebb2b | ||
|
|
331cfa7999 | ||
|
|
448755e217 | ||
|
|
9c4e9f4f92 | ||
|
|
4669b24c96 | ||
|
|
f0dd74e11f | ||
|
|
2cf0000ec4 | ||
|
|
1b710cabdc | ||
|
|
0c3b10ba8a | ||
|
|
09d15bf632 | ||
|
|
a5bd59a751 | ||
|
|
cf324e6a8f | ||
|
|
66ce553f18 | ||
|
|
2179d7cfe8 | ||
|
|
2c65d6ae36 | ||
|
|
d2b56b1a2e | ||
|
|
35b88b0e59 | ||
|
|
4c015db67c | ||
|
|
a3a57ef113 | ||
|
|
39a762615d | ||
|
|
bf7e612d0a | ||
|
|
a616484cf0 | ||
|
|
9098a6af83 | ||
|
|
66c15775e5 | ||
|
|
8fb9f53fa9 | ||
|
|
a21814f11a | ||
|
|
80d981d89a | ||
|
|
f0317722df | ||
|
|
e5e686249c | ||
|
|
cd9269bd8c | ||
|
|
be502bfd99 | ||
|
|
d1cf1449d2 | ||
|
|
86e7028570 | ||
|
|
cce529a06f | ||
|
|
3c370e7e95 | ||
|
|
5d870b8e66 | ||
|
|
a027fb6e51 | ||
|
|
1a5f14ec6e | ||
|
|
5b7e545f5c | ||
|
|
e3a5fd3ce6 | ||
|
|
2bd740b789 | ||
|
|
aacade14ea | ||
|
|
3098632a5b | ||
|
|
d7414a5916 | ||
|
|
a3a10ebeb6 | ||
|
|
25877a53a2 | ||
|
|
de5deec490 | ||
|
|
c636c307a5 | ||
|
|
91c7b37b70 | ||
|
|
59bed6f787 | ||
|
|
f58cfa1a83 | ||
|
|
c66358e4da | ||
|
|
889c7c5bb4 | ||
|
|
3400326a5b | ||
|
|
e981be8643 | ||
|
|
77712bf584 | ||
|
|
c65e15b090 | ||
|
|
09b9a4f71d | ||
|
|
2f3179c9f0 | ||
|
|
ed456932a6 | ||
|
|
3606c1f69c | ||
|
|
7ceffa5b09 | ||
|
|
14ecb7f776 | ||
|
|
a398a17b07 | ||
|
|
e12c42e469 | ||
|
|
2a16e36bc8 | ||
|
|
567a1da6cd | ||
|
|
515fa17cca | ||
|
|
9a995ad461 | ||
|
|
075a903b00 | ||
|
|
05ddc43d47 | ||
|
|
4c5704d2b4 | ||
|
|
2ad7a2b0e1 | ||
|
|
5fb64f0d62 | ||
|
|
663828ad73 | ||
|
|
be47fd0f0f | ||
|
|
3b75c78a91 | ||
|
|
71206c2bcc | ||
|
|
e2dd8489a7 | ||
|
|
02e7249bfc | ||
|
|
1bd0b885be | ||
|
|
705fe123d3 | ||
|
|
4611c75a8d | ||
|
|
7ccb4d846a | ||
|
|
1fdcfdd0c9 | ||
|
|
8144b31bfd | ||
|
|
7b695ed0c7 | ||
|
|
c484ad2640 | ||
|
|
2f2c3061cc | ||
|
|
2b3629d027 | ||
|
|
7be7ee228e | ||
|
|
2e85bd6564 | ||
|
|
98443995bd | ||
|
|
c61c103660 | ||
|
|
c8428161f0 | ||
|
|
808d03f302 | ||
|
|
f8397cbb0a | ||
|
|
ba0d418637 | ||
|
|
4bada9606f | ||
|
|
e29f2ecd72 | ||
|
|
49f87fa6eb | ||
|
|
f555b61f4c | ||
|
|
d4e0a0328a | ||
|
|
5564b7c275 | ||
|
|
f0d7bd629e | ||
|
|
e44b07c075 | ||
|
|
222e6024c3 | ||
|
|
30fc75a5df | ||
|
|
758f5b33fb | ||
|
|
a01a38fe36 | ||
|
|
660ba02e2d | ||
|
|
6c8ac2c770 | ||
|
|
fe16c59bc4 | ||
|
|
17f2ddf41c | ||
|
|
9e11fc0a87 | ||
|
|
daaf6c0b48 | ||
|
|
bb761ecaeb | ||
|
|
8b92f8f477 | ||
|
|
4f72879c54 | ||
|
|
8be1f4233f | ||
|
|
49e61f02ed | ||
|
|
8934f72ba8 | ||
|
|
c1ebf5d1b0 | ||
|
|
d53114fa82 | ||
|
|
9b5c36e232 | ||
|
|
f4271d7c16 | ||
|
|
78dba05d8f | ||
|
|
0e927e6f11 | ||
|
|
9ad735b9d7 | ||
|
|
4d897de20b | ||
|
|
6f19f06634 | ||
|
|
fc06585938 | ||
|
|
54ab5dcf00 | ||
|
|
e5e1b74fd8 | ||
|
|
5ea88533e0 | ||
|
|
594394ab30 | ||
|
|
c3ef0a06e7 | ||
|
|
531212c812 | ||
|
|
2e9827a259 | ||
|
|
97d119b4e8 | ||
|
|
2f6d259615 | ||
|
|
6c769f38ae | ||
|
|
bca4513187 | ||
|
|
9c3058f756 | ||
|
|
28b72d46c5 | ||
|
|
cece5aca30 | ||
|
|
713de31f64 | ||
|
|
e2a48d1c36 | ||
|
|
b22c42ea85 | ||
|
|
ef0ea5daad | ||
|
|
a21cf57a11 | ||
|
|
9142f57bc8 | ||
|
|
6c36da40e0 | ||
|
|
584fa2a9b5 | ||
|
|
306c6625e9 | ||
|
|
46f674f842 | ||
|
|
dca3bb89fe | ||
|
|
e74a1fe461 | ||
|
|
581828bf94 | ||
|
|
2c0d3c1a86 | ||
|
|
6c09ec2cc8 | ||
|
|
4940a24423 | ||
|
|
62556429dc | ||
|
|
5435eb0f17 | ||
|
|
765cef8a4e | ||
|
|
370444eed0 | ||
|
|
29616ad502 | ||
|
|
79fca90886 | ||
|
|
f918fc711a | ||
|
|
cc0f8f8a50 | ||
|
|
eeceb9c2de | ||
|
|
c4427d2a14 | ||
|
|
2a1132387f | ||
|
|
ca83704cbb | ||
|
|
180e2f612a | ||
|
|
f2db04bd64 | ||
|
|
7b84a7ae91 | ||
|
|
ffad9e1f0a | ||
|
|
1131a2910d | ||
|
|
18217532af | ||
|
|
6d69bba15e | ||
|
|
71b2f110eb | ||
|
|
b6c77e12f3 | ||
|
|
5bbb61aec2 | ||
|
|
1f98f3ca89 | ||
|
|
0cd3410a2d | ||
|
|
4afb5748f2 | ||
|
|
6b1d376e6f | ||
|
|
008c4f91cd | ||
|
|
85f34393fe | ||
|
|
59bdb00599 | ||
|
|
73aef0ce8e | ||
|
|
29ab66935e | ||
|
|
80fd68e1c2 | ||
|
|
f92cf816b1 | ||
|
|
ff28fd8390 | ||
|
|
0c9bddecda | ||
|
|
a19cad22cb | ||
|
|
7115c708e1 | ||
|
|
82c8398c6b | ||
|
|
76bebec833 | ||
|
|
a934dac2bd | ||
|
|
87d577e566 | ||
|
|
b5794fa5b3 | ||
|
|
4e82a62489 | ||
|
|
9ada76e00a | ||
|
|
2c894783aa | ||
|
|
9266e0c7c9 | ||
|
|
a68ee8009f | ||
|
|
909f15b909 | ||
|
|
da57350af5 | ||
|
|
bf64f868d1 | ||
|
|
0d496fdc8f | ||
|
|
4cad82b4a9 | ||
|
|
8a5d7bb78d | ||
|
|
400d9e6115 | ||
|
|
04b0b9aed5 | ||
|
|
5e27fd3421 | ||
|
|
e544924504 | ||
|
|
29754b857d | ||
|
|
8144385ce6 | ||
|
|
11e1ff12d0 | ||
|
|
26aef75d7c | ||
|
|
25c53cc803 | ||
|
|
348054d26e | ||
|
|
c05dc8af22 | ||
|
|
a45d0de43e | ||
|
|
688733132d | ||
|
|
9dd6933dab | ||
|
|
87b533c5a9 | ||
|
|
7f6e47b02e | ||
|
|
7fdd57ad1e | ||
|
|
f6db8b1f56 | ||
|
|
dca96dd1df | ||
|
|
e0e484e44b | ||
|
|
b3e1542e09 | ||
|
|
57e8de0007 | ||
|
|
861e7279e0 | ||
|
|
7673d38ed7 | ||
|
|
426b3c83cf | ||
|
|
f2856845f4 | ||
|
|
1d93086e33 | ||
|
|
00e15171d5 | ||
|
|
960b8aabad | ||
|
|
3f1181bab4 | ||
|
|
1596b84299 | ||
|
|
190cf4e0c2 | ||
|
|
cb488cf515 | ||
|
|
a146754b0b | ||
|
|
6977659843 | ||
|
|
d2a8cf3bdf | ||
|
|
e9797d69d0 | ||
|
|
4b4361fbc0 | ||
|
|
b0ce58adea | ||
|
|
77fe538e09 | ||
|
|
d8b8246622 | ||
|
|
35a02c79a8 | ||
|
|
db985f717c | ||
|
|
e41c602359 | ||
|
|
6145077d7e | ||
|
|
512e003cbd | ||
|
|
75267d2fc6 | ||
|
|
daee960ba2 | ||
|
|
b33ae69938 | ||
|
|
2a297a43eb | ||
|
|
a529be8915 | ||
|
|
7eaa5f592b | ||
|
|
be714b38bb | ||
|
|
1a5e5eb358 | ||
|
|
d75ef5e4f7 | ||
|
|
414a2b82a8 | ||
|
|
9958fe0e49 | ||
|
|
fba004eb3d | ||
|
|
2d70e55fbb | ||
|
|
d0df238972 | ||
|
|
3fe301aaa1 | ||
|
|
d97b58fd4a | ||
|
|
4e96037c78 | ||
|
|
fbdd04d817 | ||
|
|
aed4e26f59 | ||
|
|
9b88df32d3 | ||
|
|
b5adad15eb | ||
|
|
d17df3ca05 | ||
|
|
2d72e987c5 | ||
|
|
5b066b1b78 | ||
|
|
9ae3882c1a | ||
|
|
89c195fa6e | ||
|
|
de808e3612 | ||
|
|
4575f4f1df | ||
|
|
54b1c4f941 | ||
|
|
8a309c393d | ||
|
|
58a99a2ce9 | ||
|
|
23019a7bf9 | ||
|
|
8aad511579 | ||
|
|
94600b266f | ||
|
|
78a5ee0e55 | ||
|
|
63ee634ee0 | ||
|
|
752138e866 | ||
|
|
c30d51d65e | ||
|
|
c439b806b8 | ||
|
|
50fdcad10f | ||
|
|
c74626448c | ||
|
|
2886b8f9ba | ||
|
|
9ca7162169 | ||
|
|
de2df51193 | ||
|
|
dc36089156 | ||
|
|
d248ab2605 | ||
|
|
08c959807d | ||
|
|
796dc320d9 | ||
|
|
f1949c3bda | ||
|
|
4d9ead721e | ||
|
|
10d2b202d5 | ||
|
|
b8d0590d6d | ||
|
|
36ebd0c545 | ||
|
|
bc2dd35cc3 | ||
|
|
b5a735c8d1 | ||
|
|
8c0ed0e591 | ||
|
|
849b5b663b | ||
|
|
b65b0a6a7d | ||
|
|
97f8bca9da | ||
|
|
f1181d4a25 | ||
|
|
c5a37c02de | ||
|
|
74dc389321 | ||
|
|
7d7a85d184 | ||
|
|
de1e5f7292 | ||
|
|
9bc05faf53 | ||
|
|
575cef1d26 | ||
|
|
0cc3f567fc | ||
|
|
cb97c5496e | ||
|
|
8ca15ae2ca | ||
|
|
0c127ec1d9 | ||
|
|
10872bdd64 | ||
|
|
3b24aceb04 | ||
|
|
a4ccf205f7 | ||
|
|
9dbd0a4baf | ||
|
|
9aabeb39ee | ||
|
|
605e8a1488 | ||
|
|
e6587dce6a | ||
|
|
dfd0601163 | ||
|
|
034af45777 | ||
|
|
0b3e40f9d4 | ||
|
|
a44581eaf3 | ||
|
|
886a84dea0 | ||
|
|
79c3896449 | ||
|
|
a45c73bb36 | ||
|
|
6823e34451 | ||
|
|
f25fbb6f2e | ||
|
|
872409ea57 | ||
|
|
7f5e3384b3 | ||
|
|
2277c85fc3 | ||
|
|
e98d939919 | ||
|
|
81b4c09ee6 | ||
|
|
5b89508fe9 | ||
|
|
b92dd80566 | ||
|
|
4084bcdf75 | ||
|
|
4eb95c4d3e | ||
|
|
5657080884 | ||
|
|
708702917c | ||
|
|
4c8ff02969 | ||
|
|
fe167781c4 | ||
|
|
fe8565b874 | ||
|
|
0d02a4c722 | ||
|
|
9d4c984e03 | ||
|
|
e4cd56ed7b | ||
|
|
b32989cf1e | ||
|
|
f2424043f3 | ||
|
|
fece9d85e3 | ||
|
|
45996d8d78 | ||
|
|
7569f9615a | ||
|
|
90501f9ab4 | ||
|
|
c1844dfe57 | ||
|
|
bf527e7a13 | ||
|
|
472da6509d | ||
|
|
dbef78bcb8 | ||
|
|
b283f2ad31 | ||
|
|
6dcf4b3e8b | ||
|
|
bf508c2842 | ||
|
|
564b230ab1 | ||
|
|
555ddb94bf | ||
|
|
e92937d956 | ||
|
|
a6d2eb9d64 | ||
|
|
d18197a176 | ||
|
|
b28108128d | ||
|
|
dd3697bb4d |
No files matched your search
@@ -11,7 +11,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH` and `HORIZON_LAUNCHER_CLIPBOARD` pointing into the scratch home. Never write to
|
||||
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
|
||||
Generated
+7
@@ -1398,6 +1398,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
@@ -3767,6 +3768,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
|
||||
@@ -174,19 +174,31 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
style.click = match e.get("action").and_then(Value::as_str) {
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// HoverEvent.ShowText.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
|
||||
&& e.get("action").and_then(Value::as_str) == Some("show_text")
|
||||
&& let Some(value) = e.get("value")
|
||||
{
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
// HoverEvent.ShowText and ShowItem.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("show_text") => {
|
||||
if let Some(value) = e.get("value") {
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
}
|
||||
}
|
||||
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
|
||||
style.hover_item = Some(Box::new(Value::Object(e.clone())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
style.insertion = o
|
||||
.get("insertion")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! language used by every screen.
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{Arc, RwLock},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -23,11 +26,16 @@ impl LanguageInfo {
|
||||
#[derive(Default)]
|
||||
pub struct Language {
|
||||
pub code: String,
|
||||
/// `LanguageInfo.bidirectional`: text without a strong character reads
|
||||
/// right to left (`ClientLanguage.defaultRightToLeft`).
|
||||
bidirectional: bool,
|
||||
map: HashMap<String, String>,
|
||||
}
|
||||
pub const DEFAULT: &str = "en_us";
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// A source of resource files: the prepared root plus packs, highest priority last.
|
||||
pub trait ResourceSource {
|
||||
@@ -134,8 +142,12 @@ impl Language {
|
||||
{
|
||||
apply_deprecated(&mut map, &bytes);
|
||||
}
|
||||
let bidirectional = available(sources)
|
||||
.iter()
|
||||
.any(|info| info.code == code && info.bidirectional);
|
||||
Self {
|
||||
code: code.to_owned(),
|
||||
bidirectional,
|
||||
map,
|
||||
}
|
||||
}
|
||||
@@ -197,11 +209,16 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
|
||||
}
|
||||
|
||||
pub fn set(language: Language) {
|
||||
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
/// `ClientLanguage.defaultRightToLeft` of the current language.
|
||||
pub fn bidirectional() -> bool {
|
||||
BIDIRECTIONAL.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn code() -> String {
|
||||
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
|
||||
}
|
||||
|
||||
@@ -662,10 +662,22 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
|
||||
}
|
||||
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
|
||||
}
|
||||
/// `ServerPlayer.die`: whether a player's death message reaches the player
|
||||
/// themselves (the team broadcasts leave out the dying player).
|
||||
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
|
||||
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
|
||||
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
|
||||
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
|
||||
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
|
||||
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
|
||||
let Some(team) = board.players_team(dying) else {
|
||||
return true;
|
||||
};
|
||||
let same_team = board
|
||||
.players_team(other)
|
||||
.is_some_and(|t| t.name == team.name);
|
||||
match team.death_message_visibility {
|
||||
Visibility::Always => true,
|
||||
Visibility::Never => false,
|
||||
Visibility::HideForOtherTeams => same_team && other != dying,
|
||||
Visibility::HideForOwnTeam => !same_team,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- objectives and scores ---------------------------------------------
|
||||
|
||||
@@ -43,6 +43,8 @@ pub fn named_color(name: &str) -> Option<u32> {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClickEvent {
|
||||
SuggestCommand(String),
|
||||
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
|
||||
RunCommand(String),
|
||||
CopyToClipboard(String),
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
@@ -61,6 +63,10 @@ pub struct Style {
|
||||
pub click: Option<ClickEvent>,
|
||||
/// Hover text.
|
||||
pub hover: Option<Box<Text>>,
|
||||
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
|
||||
pub hover_item: Option<Box<Value>>,
|
||||
/// `Style.insertion`: the text a Shift-click puts in the chat box.
|
||||
pub insertion: Option<String>,
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
/// `minecraft:alt`; the default font when unset.
|
||||
pub font: Option<String>,
|
||||
@@ -90,6 +96,12 @@ impl Style {
|
||||
if self.hover.is_none() {
|
||||
self.hover.clone_from(&base.hover);
|
||||
}
|
||||
if self.hover_item.is_none() {
|
||||
self.hover_item.clone_from(&base.hover_item);
|
||||
}
|
||||
if self.insertion.is_none() {
|
||||
self.insertion.clone_from(&base.insertion);
|
||||
}
|
||||
if self.font.is_none() {
|
||||
self.font.clone_from(&base.font);
|
||||
}
|
||||
@@ -495,7 +507,7 @@ impl Text {
|
||||
}
|
||||
|
||||
/// The style fields of a component's JSON.
|
||||
const STYLE_KEYS: [&str; 9] = [
|
||||
const STYLE_KEYS: [&str; 10] = [
|
||||
"color",
|
||||
"bold",
|
||||
"italic",
|
||||
@@ -504,6 +516,7 @@ const STYLE_KEYS: [&str; 9] = [
|
||||
"obfuscated",
|
||||
"click_event",
|
||||
"hover_event",
|
||||
"insertion",
|
||||
"font",
|
||||
];
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
@@ -535,6 +548,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
json!({ "action": "copy_to_clipboard", "value": value })
|
||||
}
|
||||
ClickEvent::RunCommand(command) => {
|
||||
json!({ "action": "run_command", "command": command })
|
||||
}
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
@@ -544,6 +560,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
"hover_event".to_owned(),
|
||||
json!({ "action": "show_text", "value": hover.component() }),
|
||||
));
|
||||
} else if let Some(Value::Object(item)) = style.hover_item.as_deref() {
|
||||
let mut event = item.clone();
|
||||
event.insert("action".into(), "show_item".into());
|
||||
out.push(("hover_event".to_owned(), Value::Object(event)));
|
||||
}
|
||||
if let Some(insertion) = &style.insertion {
|
||||
out.push(("insertion".to_owned(), insertion.clone().into()));
|
||||
}
|
||||
if let Some(font) = &style.font {
|
||||
out.push(("font".to_owned(), font.clone().into()));
|
||||
|
||||
@@ -62,6 +62,7 @@ gilrs = { version = "0.11", optional = true }
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
|
||||
@@ -24,6 +24,7 @@ fn stack(spec: &str) -> LootStack {
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
names: Vec::new(),
|
||||
};
|
||||
for e in parts {
|
||||
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
|
||||
|
||||
@@ -223,6 +223,7 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
display_health: int("displayHealth", 0),
|
||||
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
|
||||
health_blink_time: i64::from(int("healthBlinkTime", 0)),
|
||||
..HealthHud::default()
|
||||
};
|
||||
let kind = if has("minecraft:poison") {
|
||||
HeartKind::Poisoned
|
||||
|
||||
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
}
|
||||
let partial = float(c, "partial", 0.0);
|
||||
let bg = background(c);
|
||||
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
|
||||
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
|
||||
out.extend(t.title(w, h, partial, &atlas.font, bg));
|
||||
out
|
||||
}
|
||||
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
}
|
||||
let mode = c["gameMode"].as_str().unwrap_or("survival");
|
||||
let can_hurt = mode == "survival" || mode == "adventure";
|
||||
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
|
||||
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
|
||||
}
|
||||
|
||||
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
|
||||
@@ -74,6 +74,9 @@ pub fn main() -> Result<()> {
|
||||
biomes: &biomes,
|
||||
registry: assets.registry,
|
||||
raining_at: &|_| false,
|
||||
height_no_leaves: &|_| 0,
|
||||
firefly_bush_sounds: false,
|
||||
sky_darken: 0,
|
||||
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
|
||||
};
|
||||
engine.seed_randoms(1057, 0, 0);
|
||||
|
||||
@@ -568,6 +568,10 @@ fn print_heart(
|
||||
fn player_damage(e: &MobEvent) -> f32 {
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
} => 10.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -579,6 +583,10 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
};
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
} => true,
|
||||
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
|
||||
MobEvent::Explode(at, radius) => {
|
||||
let d = Vec3::new(at[0], at[1], at[2])
|
||||
@@ -1045,6 +1053,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
alive: true,
|
||||
creative: s.creative,
|
||||
sneaking: false,
|
||||
swimming: false,
|
||||
look: [0.0, 0.0, 1.0],
|
||||
main_hand: s.hand.as_deref().map(Into::into),
|
||||
off_hand: None,
|
||||
@@ -1056,11 +1065,15 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
last_hurt: None,
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
sprinting: false,
|
||||
riding: false,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: PLAYER_UUID,
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
@@ -1070,6 +1083,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, 0, i, m, s.creative, world.game_time);
|
||||
}
|
||||
// No player has a chest open in the scenarios.
|
||||
let lid_openers = std::collections::HashMap::new();
|
||||
// The MobWorld every step sees, over a snapshot of the mobs.
|
||||
macro_rules! mob_world {
|
||||
($snapshot:expr) => {
|
||||
@@ -1081,6 +1096,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
fluid_tables: &motion.fluids,
|
||||
movement: &motion.blocks,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
@@ -1104,6 +1120,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
// FlatLevelSource.getSeaLevel.
|
||||
sea_level: -63,
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ enum Animate {
|
||||
spawn_particles: bool,
|
||||
},
|
||||
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
|
||||
/// the biome foliage colour unless a fixed colour is given (azalea).
|
||||
Leaves(Option<(T, f32)>, Option<u32>),
|
||||
/// the biome foliage colour unless a fixed colour is given (azalea); and
|
||||
/// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`).
|
||||
Leaves(Option<(T, f32)>, Option<u32>, bool),
|
||||
NetherPortal,
|
||||
EnchantingTable,
|
||||
EndRod([i32; 3]),
|
||||
@@ -48,8 +49,9 @@ enum Animate {
|
||||
CandleCake,
|
||||
BrewingStand,
|
||||
EnderChest,
|
||||
/// FallingBlock with its dust colour.
|
||||
Falling(u32),
|
||||
/// FallingBlock with its dust colour; sand also has the ambient desert sound
|
||||
/// (`SandBlock.animateTick`).
|
||||
Falling(u32, bool),
|
||||
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
|
||||
Beehive,
|
||||
RedstoneOre,
|
||||
@@ -73,6 +75,16 @@ enum Animate {
|
||||
waterlogged: bool,
|
||||
},
|
||||
EndPortal,
|
||||
/// `WetSpongeBlock.animateTick`.
|
||||
WetSponge,
|
||||
FireflyBush,
|
||||
/// An open eyeblossom (`Type.OPEN.emitSounds()`).
|
||||
OpenEyeblossom,
|
||||
PaleHangingMoss,
|
||||
/// `DryVegetationBlock`: a dead bush.
|
||||
DeadBush,
|
||||
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
|
||||
DryGrass,
|
||||
}
|
||||
fn step(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -107,17 +119,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
spawn_particles: false,
|
||||
},
|
||||
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
|
||||
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
|
||||
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
|
||||
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false),
|
||||
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true),
|
||||
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true),
|
||||
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true),
|
||||
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
|
||||
"azalea_leaves" | "flowering_azalea_leaves" => {
|
||||
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
|
||||
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false)
|
||||
}
|
||||
"spruce_leaves" => Animate::Leaves(None, None),
|
||||
"spruce_leaves" => Animate::Leaves(None, None, false),
|
||||
"nether_portal" => Animate::NetherPortal,
|
||||
"enchanting_table" => Animate::EnchantingTable,
|
||||
"end_rod" => Animate::EndRod(facing()),
|
||||
@@ -126,16 +138,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"mycelium" => Animate::Mycelium,
|
||||
"brewing_stand" => Animate::BrewingStand,
|
||||
"ender_chest" => Animate::EnderChest,
|
||||
"sand" => Animate::Falling(0xDBD3A0),
|
||||
"red_sand" => Animate::Falling(0xA95821),
|
||||
"gravel" => Animate::Falling(0x807C7B),
|
||||
"sand" => Animate::Falling(0xDBD3A0, true),
|
||||
"red_sand" => Animate::Falling(0xA95821, true),
|
||||
"gravel" => Animate::Falling(0x807C7B, false),
|
||||
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
|
||||
// DragonEggBlock's is black.
|
||||
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
|
||||
"dragon_egg" => Animate::Falling(0x000000),
|
||||
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
|
||||
name.trim_end_matches("_concrete_powder"),
|
||||
)),
|
||||
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false),
|
||||
"dragon_egg" => Animate::Falling(0x000000, false),
|
||||
_ if name.ends_with("_concrete_powder") => Animate::Falling(
|
||||
concrete_powder_color(name.trim_end_matches("_concrete_powder")),
|
||||
false,
|
||||
),
|
||||
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
|
||||
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
|
||||
"redstone_wire" => {
|
||||
@@ -167,6 +180,15 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
waterlogged: prop("waterlogged").as_deref() == Some("true"),
|
||||
},
|
||||
"end_portal" => Animate::EndPortal,
|
||||
"wet_sponge" => Animate::WetSponge,
|
||||
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
|
||||
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
|
||||
"suspicious_gravel" => Animate::Falling(0x707070, false),
|
||||
"firefly_bush" => Animate::FireflyBush,
|
||||
"open_eyeblossom" => Animate::OpenEyeblossom,
|
||||
"pale_hanging_moss" => Animate::PaleHangingMoss,
|
||||
"dead_bush" => Animate::DeadBush,
|
||||
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
|
||||
{
|
||||
@@ -233,6 +255,14 @@ pub struct AnimateTables {
|
||||
power_transmitter: Vec<bool>,
|
||||
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
|
||||
dried_ghast_sounds: Vec<bool>,
|
||||
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
|
||||
desert_sand_sounds: Vec<bool>,
|
||||
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
|
||||
poplar_leaf_logs: Vec<bool>,
|
||||
/// `#triggers_ambient_desert_dry_vegetation_block_sounds`.
|
||||
desert_dry_vegetation_sounds: Vec<bool>,
|
||||
terracotta: Vec<bool>,
|
||||
pale_oak_logs: Vec<bool>,
|
||||
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
|
||||
burnable: Vec<bool>,
|
||||
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
|
||||
@@ -308,6 +338,13 @@ impl AnimateTables {
|
||||
power_provider: by_state("minecraft:enchantment_power_provider"),
|
||||
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
|
||||
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
|
||||
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
|
||||
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
|
||||
desert_dry_vegetation_sounds: by_state(
|
||||
"minecraft:triggers_ambient_desert_dry_vegetation_block_sounds",
|
||||
),
|
||||
terracotta: by_state("minecraft:terracotta"),
|
||||
pale_oak_logs: by_state("minecraft:pale_oak_logs"),
|
||||
burnable,
|
||||
ambient,
|
||||
})
|
||||
@@ -333,6 +370,12 @@ pub struct AnimateContext<'a> {
|
||||
pub registry: &'a BlockRegistry,
|
||||
/// `Level.isRainingAt`.
|
||||
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
|
||||
/// `getHeight(MOTION_BLOCKING_NO_LEAVES, x, z)` at a position's column.
|
||||
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
|
||||
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
|
||||
pub firefly_bush_sounds: bool,
|
||||
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
|
||||
pub sky_darken: i32,
|
||||
/// Creative main-hand marker block and a resolver for each placed state's sprite.
|
||||
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
|
||||
}
|
||||
@@ -383,6 +426,12 @@ impl AnimateContext<'_> {
|
||||
],
|
||||
))
|
||||
}
|
||||
/// `Level.getMaxLocalRawBrightness`: the brighter of block light and
|
||||
/// sky light less the sky darkening.
|
||||
fn max_local_raw_brightness(&self, p: [i32; 3]) -> i32 {
|
||||
let light = self.world.light(p[0], p[1], p[2]);
|
||||
i32::from(light & 15).max(i32::from(light >> 4) - self.sky_darken)
|
||||
}
|
||||
fn foliage(&self, p: [i32; 3]) -> u32 {
|
||||
let biome = self
|
||||
.world
|
||||
@@ -396,6 +445,10 @@ impl AnimateContext<'_> {
|
||||
fn add(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
|
||||
engine.add(&ParticleOptions::simple(ty), p, v);
|
||||
}
|
||||
/// `Level.addAlwaysVisibleParticle`: still shown under the Minimal option now and then.
|
||||
fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
|
||||
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
|
||||
}
|
||||
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
|
||||
engine.sounds.push((name, p, volume, pitch));
|
||||
}
|
||||
@@ -434,7 +487,7 @@ fn do_animate_tick(
|
||||
return;
|
||||
}
|
||||
let state = ctx.state(pos);
|
||||
block_animate_tick(tables, engine, ctx, state, pos);
|
||||
block_animate_tick(tables, engine, ctx, state, pos, center);
|
||||
let facts = ctx.facts.get(state);
|
||||
if facts.fluid != Fluid::None {
|
||||
fluid_animate_tick(
|
||||
@@ -590,6 +643,22 @@ fn beehive_drip(
|
||||
];
|
||||
add(engine, T::DrippingHoney, p, [0.0; 3]);
|
||||
}
|
||||
/// `AmbientDesertBlockSoundsPlayer.shouldPlayDesertDryVegetationBlockSounds`:
|
||||
/// sand or terracotta two blocks deep.
|
||||
fn desert_dry_vegetation_sounds(
|
||||
tables: &AnimateTables,
|
||||
ctx: &AnimateContext,
|
||||
below: [i32; 3],
|
||||
) -> bool {
|
||||
let triggers = |p: [i32; 3]| {
|
||||
tables
|
||||
.desert_dry_vegetation_sounds
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
triggers(below) && triggers([below[0], below[1] - 1, below[2]])
|
||||
}
|
||||
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
|
||||
fn fluid_animate_tick(
|
||||
engine: &mut ParticleEngine,
|
||||
@@ -665,6 +734,7 @@ fn block_animate_tick(
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
player: [i32; 3],
|
||||
) {
|
||||
let (fx, fy, fz) = (f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
|
||||
match tables.kind(state) {
|
||||
@@ -771,7 +841,7 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Leaves(falling, fixed_tint) => {
|
||||
Animate::Leaves(falling, fixed_tint, poplar) => {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
// LeavesBlock.makeDrippingWaterParticles.
|
||||
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
|
||||
@@ -783,6 +853,9 @@ fn block_animate_tick(
|
||||
add(engine, T::DrippingWater, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
if poplar {
|
||||
leaves_ambient_sound(tables, engine, ctx, state, pos);
|
||||
}
|
||||
// FallingParticlesLeavesBlock.makeFallingLeavesParticles.
|
||||
if let Some((ty, chance)) = falling
|
||||
&& engine.random().next_float() < chance
|
||||
@@ -955,7 +1028,7 @@ fn block_animate_tick(
|
||||
fy + 1.0 + f64::from(r.next_float()),
|
||||
fz + f64::from(r.next_float()),
|
||||
];
|
||||
add(engine, T::SulfurBubbles, p, [0.0; 3]);
|
||||
add_visible(engine, T::SulfurBubbles, p, [0.0; 3]);
|
||||
}
|
||||
if next_int(engine.random(), 10) == 0 {
|
||||
let name = "minecraft:block.potent_sulfur.noxious_gas";
|
||||
@@ -963,6 +1036,36 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::WetSponge => {
|
||||
// Direction.getRandom: DOWN, UP, NORTH, SOUTH, WEST, EAST.
|
||||
const DIRECTIONS: [[i32; 3]; 6] = [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
];
|
||||
let d = DIRECTIONS[next_int(engine.random(), 6) as usize];
|
||||
// The sponge occludes, so only a sturdy neighbour face hides the drip.
|
||||
if d != [0, 1, 0] && !ctx.sturdy([pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]) {
|
||||
let r = engine.random();
|
||||
let p = match d {
|
||||
[0, -1, 0] => [fx + r.next_double(), fy - 0.05, fz + r.next_double()],
|
||||
[dx, _, 0] => {
|
||||
let y = fy + r.next_double() * 0.8;
|
||||
let z = fz + r.next_double();
|
||||
[fx + if dx > 0 { 1.1 } else { 0.05 }, y, z]
|
||||
}
|
||||
[_, _, dz] => {
|
||||
let y = fy + r.next_double() * 0.8;
|
||||
let x = fx + r.next_double();
|
||||
[x, y, fz + if dz > 0 { 1.1 } else { 0.05 }]
|
||||
}
|
||||
};
|
||||
add(engine, T::DrippingWater, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
Animate::Mycelium => {
|
||||
let r = engine.random();
|
||||
if next_int(r, 10) == 0 {
|
||||
@@ -1021,6 +1124,99 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::FireflyBush => {
|
||||
// FireflyBushBlock.animateTick: the idle sound on a quiet night
|
||||
// under open sky, and fireflies in the dark within 5 blocks.
|
||||
if next_int(engine.random(), 30) == 0
|
||||
&& ctx.firefly_bush_sounds
|
||||
&& (ctx.height_no_leaves)(pos) <= pos[1]
|
||||
{
|
||||
let name = "minecraft:block.firefly_bush.idle";
|
||||
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
if ctx.max_local_raw_brightness(pos) <= 13 {
|
||||
let r = engine.random();
|
||||
if r.next_double() <= 0.7 {
|
||||
let p = [
|
||||
fx + r.next_double() * 10.0 - 5.0,
|
||||
fy + r.next_double() * 5.0,
|
||||
fz + r.next_double() * 10.0 - 5.0,
|
||||
];
|
||||
add(engine, T::Firefly, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::OpenEyeblossom => {
|
||||
// EyeblossomBlock.animateTick: pale moss below.
|
||||
if next_int(engine.random(), 700) == 0 {
|
||||
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
|
||||
if ctx
|
||||
.registry
|
||||
.state(below)
|
||||
.is_some_and(|s| s.name == "minecraft:pale_moss_block")
|
||||
{
|
||||
let name = "minecraft:block.eyeblossom.idle";
|
||||
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::PaleHangingMoss => {
|
||||
// HangingMossBlock.animateTick: under pale oak logs or leaves.
|
||||
if next_int(engine.random(), 500) == 0 {
|
||||
let above = ctx.state([pos[0], pos[1] + 1, pos[2]]);
|
||||
let pale_leaves = ctx
|
||||
.registry
|
||||
.state(above)
|
||||
.is_some_and(|s| s.name == "minecraft:pale_oak_leaves");
|
||||
if pale_leaves
|
||||
|| tables
|
||||
.pale_oak_logs
|
||||
.get(above.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let name = "minecraft:block.pale_hanging_moss.idle";
|
||||
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::DeadBush => {
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
|
||||
if next_int(engine.random(), 130) == 0 {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
let state = ctx.state(below);
|
||||
let red_sand = ctx
|
||||
.registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.name == "minecraft:red_sand");
|
||||
let terracotta = tables
|
||||
.terracotta
|
||||
.get(state.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
if (red_sand || terracotta) && next_int(engine.random(), 3) != 0 {
|
||||
return;
|
||||
}
|
||||
if desert_dry_vegetation_sounds(tables, ctx, below) {
|
||||
let name = "minecraft:block.deadbush.idle";
|
||||
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::DryGrass => {
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientDryGrassSounds:
|
||||
// played at the player.
|
||||
if next_int(engine.random(), 200) == 0
|
||||
&& desert_dry_vegetation_sounds(tables, ctx, [pos[0], pos[1] - 1, pos[2]])
|
||||
{
|
||||
let at = [
|
||||
f64::from(player[0]) + 0.5,
|
||||
f64::from(player[1]),
|
||||
f64::from(player[2]) + 0.5,
|
||||
];
|
||||
sound(engine, "minecraft:block.dry_grass.ambient", at, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
Animate::EndPortal => {
|
||||
// EndPortalBlock.animateTick: one smoke particle above the surface.
|
||||
let r = engine.random();
|
||||
@@ -1045,7 +1241,7 @@ fn block_animate_tick(
|
||||
add(engine, T::Portal, p, v);
|
||||
}
|
||||
}
|
||||
Animate::Falling(color) => {
|
||||
Animate::Falling(color, sand) => {
|
||||
if next_int(engine.random(), 16) == 0 {
|
||||
let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]]));
|
||||
// FallingBlock.isFree: air, fire, liquids or replaceable.
|
||||
@@ -1062,6 +1258,14 @@ fn block_animate_tick(
|
||||
);
|
||||
}
|
||||
}
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block.
|
||||
if sand
|
||||
&& ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air
|
||||
&& next_int(engine.random(), 2100) == 0
|
||||
&& sand_sound_nearby(tables, ctx, pos)
|
||||
{
|
||||
sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
Animate::Beehive => {
|
||||
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
|
||||
@@ -1179,7 +1383,7 @@ fn block_animate_tick(
|
||||
}
|
||||
Animate::BubbleColumn { drag } => {
|
||||
if drag {
|
||||
add(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
|
||||
add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
|
||||
let r = engine.random();
|
||||
if next_int(r, 200) == 0 {
|
||||
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
|
||||
@@ -1192,7 +1396,7 @@ fn block_animate_tick(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
add_visible(
|
||||
engine,
|
||||
T::BubbleColumnUp,
|
||||
[fx + 0.5, fy, fz + 0.5],
|
||||
@@ -1204,7 +1408,7 @@ fn block_animate_tick(
|
||||
fy + f64::from(r.next_float()),
|
||||
fz + f64::from(r.next_float()),
|
||||
];
|
||||
add(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
|
||||
add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
|
||||
let r = engine.random();
|
||||
if next_int(r, 200) == 0 {
|
||||
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
|
||||
@@ -1282,6 +1486,91 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves:
|
||||
/// one log and three leaves of the same kind around the block.
|
||||
fn leaves_ambient_sound(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
if next_int(engine.random(), 300) != 0 {
|
||||
return;
|
||||
}
|
||||
let Some(name) = ctx.registry.state(state).map(|s| &s.name) else {
|
||||
return;
|
||||
};
|
||||
let (mut logs, mut leaves) = (0, 0);
|
||||
for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
|
||||
let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]);
|
||||
if tables
|
||||
.poplar_leaf_logs
|
||||
.get(neighbor.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
logs += 1;
|
||||
}
|
||||
if ctx
|
||||
.registry
|
||||
.state(neighbor)
|
||||
.is_some_and(|s| &s.name == name)
|
||||
{
|
||||
leaves += 1;
|
||||
}
|
||||
if logs == 1 && leaves == 3 {
|
||||
let p = pos.map(f64::from);
|
||||
sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
|
||||
/// surface column eight blocks away on all four sides.
|
||||
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
|
||||
let is_sand = |p: [i32; 3]| {
|
||||
tables
|
||||
.desert_sand_sounds
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let (mut found, mut checked) = (0, 0);
|
||||
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
|
||||
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
|
||||
// columnContainsTriggeringBlock.
|
||||
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
|
||||
let matches = if (surface - pos[1]).abs() > 5 {
|
||||
let mut y = pos[1] + 6;
|
||||
let mut above_air = ctx.is_air([x, y, z]);
|
||||
y -= 1;
|
||||
let mut hit = false;
|
||||
for _ in 0..10 {
|
||||
if above_air && is_sand([x, y, z]) {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
above_air = ctx.is_air([x, y, z]);
|
||||
y -= 1;
|
||||
}
|
||||
hit
|
||||
} else {
|
||||
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
|
||||
};
|
||||
if matches {
|
||||
found += 1;
|
||||
if found > 3 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
if 4 - checked + found < 3 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `AbstractCandleBlock.addParticlesAndSound`.
|
||||
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
|
||||
let r = engine.random();
|
||||
|
||||
@@ -719,11 +719,6 @@ impl Audio {
|
||||
self.play(event, at, volume, pitch);
|
||||
self.capture = capture;
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
|
||||
/// client (the entity's own random there, never the server's world random).
|
||||
pub fn hurt_pitch(&mut self) -> f32 {
|
||||
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
|
||||
}
|
||||
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
|
||||
pub fn play_button_click(&mut self) {
|
||||
self.play_instance(
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Right-to-left text (`FormattedBidiReorder.reorder`, behind
|
||||
//! `Language.getVisualOrder`): the logical text a player types becomes the
|
||||
//! glyphs a font draws left to right. Arabic letters are shaped into their
|
||||
//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the
|
||||
//! text is reordered by the Unicode bidirectional algorithm, and characters in
|
||||
//! right-to-left runs are mirrored. Left-to-right text comes back unchanged.
|
||||
use crate::text::{Span, Text};
|
||||
use std::borrow::Cow;
|
||||
use unicode_bidi::{
|
||||
BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction,
|
||||
};
|
||||
|
||||
/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins
|
||||
/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over
|
||||
/// (diacritics) and `None` never joins.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Join {
|
||||
None,
|
||||
Right,
|
||||
Dual,
|
||||
Mark,
|
||||
}
|
||||
use Join::{Dual, Mark, Right};
|
||||
|
||||
/// ICU's joining table (`araLink`): code point ranges and their class, with the
|
||||
/// lam-alef ligatures at the end. Anything else is `Join::None`.
|
||||
const JOINS: &[(u32, u32, Join)] = &[
|
||||
(0x0622, 0x0625, Right),
|
||||
(0x0626, 0x0626, Dual),
|
||||
(0x0627, 0x0627, Right),
|
||||
(0x0628, 0x0628, Dual),
|
||||
(0x0629, 0x0629, Right),
|
||||
(0x062A, 0x062E, Dual),
|
||||
(0x062F, 0x0632, Right),
|
||||
(0x0633, 0x063A, Dual),
|
||||
(0x0640, 0x0647, Dual),
|
||||
(0x0648, 0x0649, Right),
|
||||
(0x064A, 0x064A, Dual),
|
||||
(0x064B, 0x0656, Mark),
|
||||
(0x0670, 0x0670, Mark),
|
||||
(0x0671, 0x0673, Right),
|
||||
(0x0675, 0x0677, Right),
|
||||
(0x0678, 0x0687, Dual),
|
||||
(0x0688, 0x0699, Right),
|
||||
(0x069A, 0x06B9, Dual),
|
||||
(0x06BA, 0x06BA, Right),
|
||||
(0x06BB, 0x06BF, Dual),
|
||||
(0x06C0, 0x06C0, Right),
|
||||
(0x06C1, 0x06C1, Dual),
|
||||
(0x06C2, 0x06CB, Right),
|
||||
(0x06CC, 0x06CC, Dual),
|
||||
(0x06CD, 0x06CD, Right),
|
||||
(0x06CE, 0x06D1, Dual),
|
||||
(0x06D2, 0x06D3, Right),
|
||||
(0x200D, 0x200D, Dual),
|
||||
(0x206D, 0x206F, Mark),
|
||||
(0xFEF5, 0xFEFC, Right),
|
||||
];
|
||||
|
||||
/// Letters that have joined forms and the code point of the isolated one; the
|
||||
/// final, initial and medial forms follow it (a right-joining letter has the
|
||||
/// isolated and final only). The lam-alef ligatures are their own letters.
|
||||
/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to
|
||||
/// U+06D3) unchanged, so they are in `JOINS` but not here.
|
||||
#[rustfmt::skip]
|
||||
const FORMS: &[(u32, u32)] = &[
|
||||
(0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89),
|
||||
(0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99),
|
||||
(0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB),
|
||||
(0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9),
|
||||
(0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD),
|
||||
(0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1),
|
||||
(0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1),
|
||||
(0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB),
|
||||
];
|
||||
|
||||
/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two
|
||||
/// neighbouring characters that mirror each other.
|
||||
const MIRRORED: &str = "\
|
||||
()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\
|
||||
≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\
|
||||
⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\
|
||||
⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\
|
||||
⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\
|
||||
⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\
|
||||
⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\
|
||||
⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\
|
||||
〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」";
|
||||
|
||||
fn join(c: char) -> Join {
|
||||
let c = c as u32;
|
||||
JOINS
|
||||
.iter()
|
||||
.find(|&&(from, to, _)| (from..=to).contains(&c))
|
||||
.map_or(Join::None, |&(_, _, join)| join)
|
||||
}
|
||||
|
||||
/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`.
|
||||
fn letter_of(c: char) -> char {
|
||||
let c32 = c as u32;
|
||||
if !matches!(c32, 0xFE70..=0xFEFC) {
|
||||
return c;
|
||||
}
|
||||
if (0xFE70..0xFE80).contains(&c32) {
|
||||
// The diacritics' forms pair up from fathatan on.
|
||||
return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c);
|
||||
}
|
||||
FORMS
|
||||
.iter()
|
||||
.find(|&&(letter, iso)| {
|
||||
let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual {
|
||||
4
|
||||
} else {
|
||||
2
|
||||
};
|
||||
(iso..iso + forms).contains(&c32)
|
||||
})
|
||||
.and_then(|&(letter, _)| char::from_u32(letter))
|
||||
.unwrap_or(c)
|
||||
}
|
||||
|
||||
/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics
|
||||
/// may sit between) becomes one ligature, then every letter takes the isolated,
|
||||
/// final, initial or medial form its neighbours call for. Diacritics take the
|
||||
/// tatweel form between two joined letters. The text can only get shorter.
|
||||
fn shape(text: &[char]) -> Vec<char> {
|
||||
let letters: Vec<char> = text.iter().map(|&c| letter_of(c)).collect();
|
||||
let mut chars = Vec::with_capacity(letters.len());
|
||||
let mut i = 0;
|
||||
while i < letters.len() {
|
||||
if letters[i] == '\u{644}' {
|
||||
let mut at = i + 1;
|
||||
while letters.get(at).is_some_and(|&c| join(c) == Mark) {
|
||||
at += 1;
|
||||
}
|
||||
let alef = letters.get(at).and_then(|&c| {
|
||||
['\u{622}', '\u{623}', '\u{625}', '\u{627}']
|
||||
.iter()
|
||||
.position(|&a| a == c)
|
||||
});
|
||||
if let Some(alef) = alef {
|
||||
chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}'));
|
||||
chars.extend_from_slice(&letters[i + 1..at]);
|
||||
i = at + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
chars.push(letters[i]);
|
||||
i += 1;
|
||||
}
|
||||
// Whether the nearest letter before joins the next one, and the joins of
|
||||
// the nearest letter after.
|
||||
let mut joins_next = false;
|
||||
let mut out = Vec::with_capacity(chars.len());
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
let next_joins = chars[i + 1..]
|
||||
.iter()
|
||||
.map(|&n| join(n))
|
||||
.find(|&j| j != Mark)
|
||||
.is_some_and(|j| j != Join::None);
|
||||
let kind = join(c);
|
||||
let shaped = match kind {
|
||||
Join::None => {
|
||||
joins_next = false;
|
||||
c
|
||||
}
|
||||
Mark => match c as u32 {
|
||||
m @ 0x064B..=0x0652 => {
|
||||
let iso = 0xFE70 + 2 * (m - 0x064B);
|
||||
let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D);
|
||||
char::from_u32(iso + u32::from(tatweel)).unwrap_or(c)
|
||||
}
|
||||
_ => c,
|
||||
},
|
||||
Right | Dual => {
|
||||
let to_next = kind == Dual && next_joins;
|
||||
let form = match (joins_next, to_next) {
|
||||
(false, false) => 0,
|
||||
(true, false) => 1,
|
||||
(false, true) => 2,
|
||||
(true, true) => 3,
|
||||
};
|
||||
joins_next = kind == Dual;
|
||||
FORMS
|
||||
.binary_search_by_key(&(c as u32), |&(letter, _)| letter)
|
||||
.ok()
|
||||
.and_then(|at| char::from_u32(FORMS[at].1 + form))
|
||||
.unwrap_or(c)
|
||||
}
|
||||
};
|
||||
out.push(shaped);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `UCharacter.getMirror`.
|
||||
fn mirror(c: char) -> char {
|
||||
if bidi_class(c) != BidiClass::ON {
|
||||
return c;
|
||||
}
|
||||
let mut pairs = MIRRORED.chars();
|
||||
while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) {
|
||||
if c == a {
|
||||
return b;
|
||||
}
|
||||
if c == b {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Whether a character can change anything: right-to-left letters and numbers,
|
||||
/// the explicit direction controls and the diacritics that shaping turns into
|
||||
/// letters. Without any, left-to-right text keeps its order.
|
||||
fn directional(c: char) -> bool {
|
||||
c as u32 >= 0x0590
|
||||
&& (matches!(c as u32, 0x064B..=0x0652)
|
||||
|| matches!(
|
||||
bidi_class(c),
|
||||
BidiClass::R
|
||||
| BidiClass::AL
|
||||
| BidiClass::AN
|
||||
| BidiClass::LRE
|
||||
| BidiClass::RLE
|
||||
| BidiClass::LRO
|
||||
| BidiClass::RLO
|
||||
| BidiClass::PDF
|
||||
| BidiClass::LRI
|
||||
| BidiClass::RLI
|
||||
| BidiClass::FSI
|
||||
| BidiClass::PDI
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether `chars` keep their order and shapes: left-to-right text without
|
||||
/// anything directional, in a language that reads left to right by default.
|
||||
fn unchanged(mut chars: impl Iterator<Item = char>, default_rtl: bool) -> bool {
|
||||
!default_rtl && !chars.any(directional)
|
||||
}
|
||||
|
||||
/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each
|
||||
/// character's position in the shaped text. A character's style is the one at
|
||||
/// its position in the shaped text, as `SubStringSource` takes it (the text
|
||||
/// shrinks at a lam-alef ligature).
|
||||
fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> {
|
||||
let shaped = shape(chars);
|
||||
let plain: String = shaped.iter().collect();
|
||||
// DEFAULT_RTL only changes text without a strong character.
|
||||
let level =
|
||||
(default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl);
|
||||
let info = ParagraphBidiInfo::new(&plain, level);
|
||||
let levels = info.reordered_levels_per_char(0..plain.len());
|
||||
ParagraphBidiInfo::reorder_visual(&levels)
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let c = if levels[i].is_rtl() {
|
||||
mirror(shaped[i])
|
||||
} else {
|
||||
shaped[i]
|
||||
};
|
||||
(c, i)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for
|
||||
/// the current language's direction.
|
||||
pub fn visual(text: &Text) -> Cow<'_, Text> {
|
||||
let default_rtl = crate::lang::bidirectional();
|
||||
if unchanged(text.chars().map(|(c, _)| c), default_rtl) {
|
||||
return Cow::Borrowed(text);
|
||||
}
|
||||
let chars: Vec<char> = text.chars().map(|(c, _)| c).collect();
|
||||
let styles: Vec<_> = text.chars().map(|(_, style)| style).collect();
|
||||
let mut out = Text::default();
|
||||
for (c, at) in reorder(&chars, default_rtl) {
|
||||
match out.spans.last_mut() {
|
||||
Some(span) if span.style == *styles[at] => span.text.push(c),
|
||||
_ => out.spans.push(Span {
|
||||
text: c.to_string(),
|
||||
style: styles[at].clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The same for a plain string.
|
||||
pub fn visual_str(s: &str) -> Cow<'_, str> {
|
||||
let default_rtl = crate::lang::bidirectional();
|
||||
if unchanged(s.chars(), default_rtl) {
|
||||
return Cow::Borrowed(s);
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
Cow::Owned(
|
||||
reorder(&chars, default_rtl)
|
||||
.into_iter()
|
||||
.map(|(c, _)| c)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -66,6 +66,13 @@ impl BiomeInfo {
|
||||
}
|
||||
.height_adjusted(pos, sea_level)
|
||||
}
|
||||
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
|
||||
/// without precipitation, else whether it falls as snow
|
||||
/// (`getHeightAdjustedTemperature` below 0.15).
|
||||
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
self.precipitation
|
||||
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
|
||||
}
|
||||
}
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
|
||||
@@ -610,7 +610,9 @@ fn sign_text_calls(
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| {
|
||||
// The line that fits, in the order it is drawn (getVisualOrder).
|
||||
let shown = first_line(line, data.max_line_width(), &advance);
|
||||
let shown = crate::bidi::visual_str(&shown).into_owned();
|
||||
TextCall {
|
||||
matrix: m,
|
||||
x: -((width(&shown) as i32) / 2) as f32,
|
||||
|
||||
@@ -7,7 +7,12 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Category {
|
||||
@@ -94,6 +99,24 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
EFFECTS.iter().find(|e| e.id == id)
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.id,
|
||||
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
|
||||
/// effects as (effect, duration ticks, amplifier).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -50,10 +50,12 @@ impl Weight {
|
||||
|
||||
/// The sounds of `BundleItem` (`Entity.playSound` at the player): insert
|
||||
/// and remove one at volume 0.8 and pitch `0.8 + random * 0.4`, a failed
|
||||
/// insert at 1, 1.
|
||||
/// insert at 1, 1; the drop of its contents is played at the player's block
|
||||
/// like remove one.
|
||||
pub const INSERT: &str = "minecraft:item.bundle.insert";
|
||||
pub const INSERT_FAIL: &str = "minecraft:item.bundle.insert_fail";
|
||||
pub const REMOVE_ONE: &str = "minecraft:item.bundle.remove_one";
|
||||
pub const DROP_CONTENTS: &str = "minecraft:item.bundle.drop_contents";
|
||||
|
||||
fn max_stack(items: &Items, stack: &ItemStack) -> u32 {
|
||||
items.get(&stack.item).map_or(64, |i| i.max_stack).max(1)
|
||||
@@ -211,6 +213,17 @@ impl Contents {
|
||||
self.weight.n >= self.weight.d
|
||||
}
|
||||
|
||||
/// `BundleItem.isBarVisible`, `getBarWidth` and `getBarColor`: the fullness bar
|
||||
/// (width, RGB), blue until the bundle is full, then red.
|
||||
pub fn bar(&self) -> Option<(i32, u32)> {
|
||||
if self.weight.n <= 0 {
|
||||
return None;
|
||||
}
|
||||
let width = (1 + self.weight.n * 12 / self.weight.d).min(13) as i32;
|
||||
let color = if self.is_full() { 0xFF5454 } else { 0x7087FF };
|
||||
Some((width, color))
|
||||
}
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
let data = bundle.data_mut();
|
||||
|
||||
@@ -16,6 +16,9 @@ use crate::{
|
||||
#[path = "chat_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
|
||||
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
|
||||
@@ -80,6 +83,10 @@ impl ChatAbilities {
|
||||
ChatSource::Client => true,
|
||||
}
|
||||
}
|
||||
/// `ChatAbilities.hasAnyRestrictions`: the Chat option takes something.
|
||||
pub fn any_restriction(&self) -> bool {
|
||||
*self != Self::new(ChatVisibility::Full)
|
||||
}
|
||||
/// Whether the chat screen may send `input` (`hasAllowedInput`).
|
||||
pub fn allows(&self, input: &str) -> bool {
|
||||
if input.starts_with('/') {
|
||||
@@ -185,6 +192,10 @@ pub struct Chat {
|
||||
lines: Vec<Line>,
|
||||
/// Sent messages and commands (ChatComponent.recentChat).
|
||||
pub recent: Vec<String>,
|
||||
/// `CommandHistory`: the last commands, kept in `command_history.txt`
|
||||
/// in the game folder once `load_history` has set it.
|
||||
commands: Vec<String>,
|
||||
history_file: Option<std::path::PathBuf>,
|
||||
scroll: usize,
|
||||
new_since_scroll: bool,
|
||||
pub options: ChatOptions,
|
||||
@@ -310,6 +321,17 @@ impl Chat {
|
||||
self.lines.clear();
|
||||
self.scroll = 0;
|
||||
}
|
||||
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
|
||||
/// start the chat's history (`ChatComponent`'s constructor).
|
||||
pub fn load_history(&mut self, game_folder: &std::path::Path) {
|
||||
let file = game_folder.join(COMMAND_HISTORY_FILE);
|
||||
if let Ok(text) = std::fs::read_to_string(&file) {
|
||||
self.commands = text.lines().map(str::to_owned).collect();
|
||||
self.recent.extend(self.commands.iter().cloned());
|
||||
}
|
||||
self.history_file = Some(file);
|
||||
}
|
||||
/// `ChatComponent.addRecentChat`: a command also goes to the saved history.
|
||||
pub fn add_recent(&mut self, message: &str) {
|
||||
if self.recent.last().map(String::as_str) != Some(message) {
|
||||
if self.recent.len() >= 100 {
|
||||
@@ -317,6 +339,21 @@ impl Chat {
|
||||
}
|
||||
self.recent.push(message.to_owned());
|
||||
}
|
||||
if message.starts_with('/') && self.commands.last().map(String::as_str) != Some(message) {
|
||||
if self.commands.len() >= MAX_PERSISTED_COMMAND_HISTORY {
|
||||
self.commands.remove(0);
|
||||
}
|
||||
self.commands.push(message.to_owned());
|
||||
if let Some(file) = &self.history_file {
|
||||
let mut text = self.commands.join("\n");
|
||||
text.push('\n');
|
||||
if let Err(e) = std::fs::write(file, text) {
|
||||
log::error!(
|
||||
"Failed to write {COMMAND_HISTORY_FILE}, command history will be missing: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn reset_scroll(&mut self) {
|
||||
self.scroll = 0;
|
||||
@@ -438,10 +475,71 @@ impl Chat {
|
||||
out.push(GuiDraw::Pop);
|
||||
out
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch.
|
||||
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
|
||||
/// open the restricted prompt above the lines.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
if focused && self.abilities.any_restriction() {
|
||||
let prompt = self.restricted_prompt(&atlas.font, batch.height as i32);
|
||||
hud_layout::paint(batch, atlas, &prompt);
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.extractRenderState` for `FOREGROUND_RESTRICTED`: a
|
||||
/// background row and the red, underlined "Chat is restricted" line in
|
||||
/// the row above the messages, clipped to the chat's width.
|
||||
fn restricted_prompt(&self, font: &crate::font::Font, height: i32) -> Vec<GuiDraw> {
|
||||
let o = &self.options;
|
||||
let scale = o.scale as f32;
|
||||
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
|
||||
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
let shown = self
|
||||
.lines
|
||||
.len()
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(true));
|
||||
let top = chat_bottom - (shown as i32 + 1) * entry_height;
|
||||
let mut prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
|
||||
color: Some(text::RED),
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
});
|
||||
// ComponentRenderUtils.clipText.
|
||||
let mut width = 0.0;
|
||||
let fits = prompt
|
||||
.chars()
|
||||
.take_while(|(c, style)| {
|
||||
width += font.advance(*c, style);
|
||||
width <= max_width as f32
|
||||
})
|
||||
.count();
|
||||
if fits < prompt.chars().count() {
|
||||
prompt = prompt.slice(0, fits);
|
||||
}
|
||||
vec![
|
||||
GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
]),
|
||||
hud_layout::fill(
|
||||
-2,
|
||||
top,
|
||||
max_width + 4 + 4,
|
||||
top + entry_height,
|
||||
hud_layout::as8(o.background_opacity as f32) << 24,
|
||||
),
|
||||
GuiDraw::Text {
|
||||
text: prompt,
|
||||
x: 0,
|
||||
y: top + entry_height - bottom_to_text - 1,
|
||||
color: hud_layout::white(o.opacity as f32 * 0.9 + 0.1),
|
||||
shadow: true,
|
||||
},
|
||||
GuiDraw::Pop,
|
||||
]
|
||||
}
|
||||
/// The click action under a GUI-space cursor while chat is focused.
|
||||
pub fn click_at(
|
||||
@@ -458,26 +556,34 @@ impl Chat {
|
||||
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
|
||||
self.style_at(font, height, x, y)?.hover.map(|h| *h)
|
||||
}
|
||||
/// ChatComponent.getClickedComponentStyleAt.
|
||||
fn style_at(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<crate::text::Style> {
|
||||
/// The wrapped line under a GUI-space cursor while chat is focused, and
|
||||
/// the cursor's x within it (the text starts at 0).
|
||||
fn line_at(&self, height: i32, x: f64, y: f64) -> Option<(&Line, f64)> {
|
||||
let scale = self.options.scale as f32;
|
||||
let mut x = x / f64::from(scale) - 4.0;
|
||||
let x = x / f64::from(scale) - 4.0;
|
||||
let bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let from_bottom = f64::from(bottom) - y / f64::from(scale);
|
||||
if x < 0.0 || x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
|
||||
if x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let row = (from_bottom / f64::from(self.options.line_height())).ceil() as usize - 1;
|
||||
if row >= self.lines_per_page(true) {
|
||||
return None;
|
||||
}
|
||||
let line = self.lines.get(self.scroll + row)?;
|
||||
Some((self.lines.get(self.scroll + row)?, x))
|
||||
}
|
||||
/// ChatComponent.getClickedComponentStyleAt.
|
||||
pub fn style_at(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<crate::text::Style> {
|
||||
let (line, mut x) = self.line_at(height, x, y)?;
|
||||
if x < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let content = line.content.resolve_legacy();
|
||||
for (c, style) in content.chars() {
|
||||
x -= f64::from(font.advance(c, style));
|
||||
@@ -487,6 +593,13 @@ impl Chat {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Whether the cursor is on a line's indicator bar while chat is focused
|
||||
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
|
||||
/// message tag's text.
|
||||
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
|
||||
self.line_at(height, x, y)
|
||||
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
|
||||
}
|
||||
/// Plain text of the newest messages (for tests and the control socket).
|
||||
pub fn recent_lines(&self, n: usize) -> Vec<String> {
|
||||
self.messages
|
||||
|
||||
@@ -35,6 +35,23 @@ impl Mode {
|
||||
Self::Redstone => "minecraft:command_block",
|
||||
}
|
||||
}
|
||||
/// `Enum.ordinal`, which `ServerboundSetCommandBlockPacket` writes.
|
||||
pub fn ordinal(self) -> i32 {
|
||||
match self {
|
||||
Self::Sequence => 0,
|
||||
Self::Auto => 1,
|
||||
Self::Redstone => 2,
|
||||
}
|
||||
}
|
||||
/// The mode of a packet's ordinal; `handleSetCommandBlock` treats any
|
||||
/// other value as a plain command block (its `default` branch).
|
||||
pub fn from_ordinal(ordinal: i32) -> Self {
|
||||
match ordinal {
|
||||
0 => Self::Sequence,
|
||||
1 => Self::Auto,
|
||||
_ => Self::Redstone,
|
||||
}
|
||||
}
|
||||
/// `CycleButton`: the next value, wrapping round.
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
|
||||
@@ -63,13 +63,24 @@ impl Confirm {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: [&Text; 2],
|
||||
) {
|
||||
self.draw_active(batch, atlas, cursor, labels, true);
|
||||
}
|
||||
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
|
||||
pub fn draw_active(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: [&Text; 2],
|
||||
active: bool,
|
||||
) {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
|
||||
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
|
||||
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,22 @@ pub enum BlockEntity {
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
pub items: [Option<ItemStack>; 6],
|
||||
pub last_slot: i32,
|
||||
}
|
||||
impl Default for ChiseledBookshelf {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Default::default(),
|
||||
last_slot: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
@@ -381,6 +397,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -420,6 +439,7 @@ impl BlockEntity {
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -443,6 +463,7 @@ impl BlockEntity {
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -459,6 +480,7 @@ impl BlockEntity {
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -483,6 +505,7 @@ impl BlockEntity {
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
|
||||
@@ -179,13 +179,14 @@ impl PlayerEffects {
|
||||
self.list.clear();
|
||||
}
|
||||
/// The effects of a saved player (`Player.active_effects`: id,
|
||||
/// duration, amplifier, ambient, visible).
|
||||
/// duration, amplifier, ambient, particles visible).
|
||||
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
|
||||
let mut effects = Self::default();
|
||||
for (id, duration, amplifier, ambient, _) in saved {
|
||||
for (id, duration, amplifier, ambient, particles) in saved {
|
||||
if let Some(def) = brewing::effect(id) {
|
||||
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
|
||||
e.ambient = *ambient;
|
||||
e.visible = *particles;
|
||||
effects.add(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct ItemEntity {
|
||||
pub id: u32,
|
||||
pub stack: ItemStack,
|
||||
pub thrower: Option<[i32; 4]>,
|
||||
/// `ItemEntity.target`: the only player who may pick the item up.
|
||||
pub target: Option<[i32; 4]>,
|
||||
pub position: Vec3,
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
@@ -355,7 +357,10 @@ pub fn merge_with_neighbours(
|
||||
}
|
||||
// ItemEntity.areMergable: both fit one stack of the same item and components.
|
||||
let (this, other) = (&items[i].stack, &items[j].stack);
|
||||
if this.count + other.count > max_stack(other) || !this.same_item_same_components(other) {
|
||||
if items[i].target != items[j].target
|
||||
|| this.count + other.count > max_stack(other)
|
||||
|| !this.same_item_same_components(other)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (to, from) = if other.count < this.count {
|
||||
@@ -582,17 +587,21 @@ impl Experience {
|
||||
7 + level * 2
|
||||
}
|
||||
}
|
||||
/// Returns true when a level was gained.
|
||||
pub fn give(&mut self, points: i32) -> bool {
|
||||
let start = self.level;
|
||||
/// Returns the first multiple of 5 the player reached, where each level
|
||||
/// gained would play the level-up sound (`Player.giveExperienceLevels`).
|
||||
pub fn give(&mut self, points: i32) -> Option<i32> {
|
||||
let mut sound_level = None;
|
||||
self.progress += points as f32 / Self::needed(self.level) as f32;
|
||||
self.total = self.total.saturating_add(points).max(0);
|
||||
while self.progress >= 1.0 {
|
||||
self.progress = (self.progress - 1.0) * Self::needed(self.level) as f32;
|
||||
self.level += 1;
|
||||
if self.level % 5 == 0 {
|
||||
sound_level.get_or_insert(self.level);
|
||||
}
|
||||
self.progress /= Self::needed(self.level) as f32;
|
||||
}
|
||||
self.level > start
|
||||
sound_level
|
||||
}
|
||||
}
|
||||
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
|
||||
@@ -767,9 +776,24 @@ pub struct ArmorStand {
|
||||
pub name_visible: bool,
|
||||
/// Game time of the last hit (the wobble and the second-hit window).
|
||||
pub last_hit: i64,
|
||||
/// `DATA_HEAD_POSE` to `DATA_RIGHT_LEG_POSE`: head, body, left arm, right
|
||||
/// arm, left leg, right leg, in degrees (`POSE_KEYS` order).
|
||||
pub pose: [[f32; 3]; 6],
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
/// The `Pose` compound's keys.
|
||||
pub const POSE_KEYS: [&'static str; 6] =
|
||||
["Head", "Body", "LeftArm", "RightArm", "LeftLeg", "RightLeg"];
|
||||
/// `ArmorStand.DEFAULT_*_POSE`.
|
||||
pub const DEFAULT_POSE: [[f32; 3]; 6] = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 0.0],
|
||||
[-10.0, 0.0, -10.0],
|
||||
[-15.0, 0.0, 10.0],
|
||||
[-1.0, 0.0, -1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
];
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
fire_ticks: 0,
|
||||
@@ -788,6 +812,7 @@ impl ArmorStand {
|
||||
custom_name: None,
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
pose: Self::DEFAULT_POSE,
|
||||
}
|
||||
}
|
||||
pub fn environment_tick(
|
||||
@@ -931,6 +956,7 @@ mod fire_environment_tests {
|
||||
let make = |id: &str| ItemEntity {
|
||||
id: 1,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from(id),
|
||||
count: 1,
|
||||
@@ -1027,6 +1053,7 @@ mod merge_tests {
|
||||
ItemEntity {
|
||||
id: 0,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from(id),
|
||||
count,
|
||||
|
||||
@@ -189,6 +189,7 @@ mod tests {
|
||||
extra: None,
|
||||
},
|
||||
thrower: None,
|
||||
target: None,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
|
||||
@@ -203,7 +203,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// lit texture, then the lightmap, then the glint and fog.
|
||||
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
|
||||
color *= in.light_color;
|
||||
if in.overlay == 7u || in.overlay == 8u {
|
||||
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
|
||||
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
|
||||
// enchanted_glint sheen drifting diagonally over the item's shape.
|
||||
let px = in.uv * vec2<f32>(textureDimensions(atlas));
|
||||
@@ -219,11 +219,32 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
|
||||
// normal, so no cardinal light) wherever the texture is not fully clear.
|
||||
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
|
||||
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
|
||||
return vec4(in.color.rgb, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
return color;
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
|
||||
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
|
||||
// as breaking::stage_light packs them.
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
let bytes = round(in.color * 255.0);
|
||||
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
|
||||
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
|
||||
if color.a < 0.1 { discard; }
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
@fragment fn fs_water_mask() -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
|
||||
@@ -187,6 +187,31 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.remove(&horizon_world::nbt::JavaString::from(key));
|
||||
}
|
||||
}
|
||||
// LivingEntity.addAdditionalSaveData: `active_effects` only when there are
|
||||
// any (MobEffectInstance.CODEC; a mob's effects are never ambient, and one
|
||||
// given with `hideParticles` shows neither particles nor its icon).
|
||||
if m.extra.effects.is_empty() {
|
||||
map.remove(&horizon_world::nbt::JavaString::from("active_effects"));
|
||||
} else {
|
||||
let effects = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.map(|&(effect, duration, amplifier)| {
|
||||
let shown = !m.extra.hidden_effects.contains(&effect);
|
||||
let mut e = vec![
|
||||
("id", string(crate::brewing::namespaced(effect))),
|
||||
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
|
||||
("duration", Tag::Int(duration)),
|
||||
];
|
||||
if !shown {
|
||||
e.push(("show_particles", Tag::Byte(0)));
|
||||
}
|
||||
e.push(("show_icon", Tag::Byte(i8::from(shown))));
|
||||
Tag::compound(e)
|
||||
});
|
||||
map.insert("active_effects".into(), Tag::List(effects.collect()));
|
||||
}
|
||||
map.insert(
|
||||
"Invulnerable".into(),
|
||||
Tag::Byte(i8::from(m.invulnerable_flag)),
|
||||
@@ -278,6 +303,18 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry("sound_variant".into())
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:panda" => {
|
||||
use crate::mobs::panda::GENES;
|
||||
for (key, gene) in [
|
||||
("MainGene", m.extra.variant),
|
||||
("HiddenGene", m.extra.variant2),
|
||||
] {
|
||||
map.insert(
|
||||
key.into(),
|
||||
string(GENES[usize::from(gene).min(GENES.len() - 1)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
|
||||
// Only Piglin.addAdditionalSaveData writes CannotHunt.
|
||||
if id == "minecraft:piglin_brute" {
|
||||
@@ -426,7 +463,42 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
crate::mobs::villager::write_nbt(m, map);
|
||||
}
|
||||
"minecraft:wandering_trader" => put_offers(map, m),
|
||||
"minecraft:axolotl" => write_axolotl_memories(m, map),
|
||||
"minecraft:axolotl" => {
|
||||
write_axolotl_memories(m, map);
|
||||
map.insert(
|
||||
"Variant".into(),
|
||||
Tag::Int(i32::from(m.extra.variant.min(4))),
|
||||
);
|
||||
}
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
}
|
||||
"minecraft:parrot" => {
|
||||
map.insert(
|
||||
"Variant".into(),
|
||||
Tag::Int(i32::from(m.extra.variant.min(4))),
|
||||
);
|
||||
if let Some(owner) = m.extra.owner {
|
||||
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
|
||||
} else {
|
||||
map.remove(&"Owner".into());
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
}
|
||||
"minecraft:salmon" => {
|
||||
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
|
||||
map.insert("type".into(), string(size));
|
||||
}
|
||||
// TropicalFish.addAdditionalSaveData: the packed variant.
|
||||
"minecraft:tropical_fish" => {
|
||||
let pattern = m.extra.variant.min(11);
|
||||
let packed = i32::from(pattern >= 6)
|
||||
| i32::from(pattern % 6) << 8
|
||||
| i32::from(m.extra.variant2 & 15) << 16
|
||||
| i32::from(m.extra.variant3 & 15) << 24;
|
||||
map.insert("Variant".into(), Tag::Int(packed));
|
||||
}
|
||||
"minecraft:allay" => {
|
||||
// InventoryCarrier: the fetched stack; the liked player memory.
|
||||
let mut inv = Vec::new();
|
||||
@@ -450,7 +522,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"memories",
|
||||
Tag::compound([(
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([("value", Tag::IntArray(vec![0, 0, 0, 1]))]),
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
)]),
|
||||
)]),
|
||||
);
|
||||
@@ -500,6 +575,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
}
|
||||
"minecraft:endermite" => {
|
||||
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
"minecraft:shulker" => {
|
||||
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
@@ -606,6 +684,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// A wolf's armor keeps the durability it lost.
|
||||
if slot == "body"
|
||||
&& m.species() == crate::mobs::Species::Wolf
|
||||
&& let Some(id) = item
|
||||
{
|
||||
let mut stack = crate::items::ItemStack::new(id, 1);
|
||||
stack.damage = m.extra.body_damage.max(0) as u32;
|
||||
stack.extra = m.extra.body_extra.clone();
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
match item {
|
||||
Some(id) => {
|
||||
let keep = matches!(
|
||||
@@ -854,6 +943,25 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.no_gravity = flag(tag, "NoGravity");
|
||||
m.glowing = flag(tag, "Glowing");
|
||||
m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32;
|
||||
// LivingEntity.readAdditionalSaveData: the saved `active_effects`, the last
|
||||
// entry of an effect winning.
|
||||
if let Some(Tag::List(effects)) = tag.get("active_effects") {
|
||||
for e in effects {
|
||||
let Some(def) = text(e.get("id")).and_then(|id| crate::brewing::effect(&id)) else {
|
||||
continue;
|
||||
};
|
||||
let effect = crate::brewing::namespaced(def.id);
|
||||
let duration = number(e.get("duration")).unwrap_or(0) as i32;
|
||||
let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32;
|
||||
m.extra.effects.retain(|x| x.0 != effect);
|
||||
m.extra.effects.push((effect, duration, amplifier));
|
||||
// Details.CODEC: `show_particles` defaults to true.
|
||||
m.extra.hidden_effects.retain(|h| *h != effect);
|
||||
if number(e.get("show_particles")) == Some(0) {
|
||||
m.extra.hidden_effects.push(effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
m.silent = flag(tag, "Silent");
|
||||
m.invulnerable_flag = flag(tag, "Invulnerable");
|
||||
if m.species() == crate::mobs::Species::Bat {
|
||||
@@ -950,6 +1058,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Panda {
|
||||
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
|
||||
m.extra.variant = gene("MainGene");
|
||||
m.extra.variant2 = gene("HiddenGene");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Parrot {
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Wolf {
|
||||
let health = m.health;
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
@@ -957,6 +1075,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
crate::mobs::tame::set_tame(&mut m, tame);
|
||||
m.health = health;
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
let armor = tag
|
||||
.get("equipment")
|
||||
.and_then(|e| e.get("body"))
|
||||
.and_then(crate::save::item_from_nbt);
|
||||
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
|
||||
m.extra.body_extra = armor.and_then(|s| s.extra);
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
@@ -985,6 +1109,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
if m.species() == crate::mobs::Species::Axolotl {
|
||||
restore_axolotl_memories(&mut m, tag);
|
||||
}
|
||||
read_variant(&mut m, tag);
|
||||
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
|
||||
if m.species() == crate::mobs::Species::Allay {
|
||||
@@ -994,11 +1119,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
|
||||
m.extra.tame = tag
|
||||
let liked = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:liked_player"))
|
||||
.is_some();
|
||||
.and_then(|b| b.get("minecraft:liked_player"));
|
||||
m.extra.tame = liked.is_some();
|
||||
m.extra.owner = liked
|
||||
.and_then(|l| l.get("value"))
|
||||
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
|
||||
}
|
||||
if crate::mobs::camel::is_camel(m.species()) {
|
||||
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
|
||||
@@ -1070,6 +1198,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
|
||||
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Endermite {
|
||||
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Vex {
|
||||
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
|
||||
&& v.len() == 3
|
||||
@@ -1160,6 +1291,11 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Thrower".into());
|
||||
}
|
||||
if let Some(target) = e.target {
|
||||
put(&mut tag, "Owner", Tag::IntArray(target.to_vec()));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Owner".into());
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
|
||||
@@ -1171,6 +1307,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
id,
|
||||
stack,
|
||||
thrower: uuid_from_tag(tag.get("Thrower")),
|
||||
target: uuid_from_tag(tag.get("Owner")),
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
@@ -1689,6 +1826,15 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
}
|
||||
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
|
||||
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
|
||||
// ArmorStand.addAdditionalSaveData: the `Pose` parts that are not default.
|
||||
let pose: Vec<(&str, Tag)> = ArmorStand::POSE_KEYS
|
||||
.into_iter()
|
||||
.zip(a.pose)
|
||||
.zip(ArmorStand::DEFAULT_POSE)
|
||||
.filter(|((_, rotation), default)| rotation != default)
|
||||
.map(|((key, rotation), _)| (key, floats(&rotation)))
|
||||
.collect();
|
||||
put(&mut tag, "Pose", Tag::compound(pose));
|
||||
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
for (key, value) in [
|
||||
@@ -1728,6 +1874,19 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.invisible = flag(tag, "Invisible");
|
||||
a.marker = flag(tag, "Marker");
|
||||
a.on_ground = flag(tag, "OnGround");
|
||||
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
|
||||
// three numbers, the others staying at their defaults.
|
||||
if let Some(pose) = tag.get("Pose") {
|
||||
for (rotation, key) in a.pose.iter_mut().zip(ArmorStand::POSE_KEYS) {
|
||||
if let Some(Tag::List(values)) = pose.get(key)
|
||||
&& values.len() == 3
|
||||
{
|
||||
for (r, v) in rotation.iter_mut().zip(values) {
|
||||
*r = float(Some(v)).unwrap_or(0.0) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
|
||||
@@ -2058,6 +2217,7 @@ mod tests {
|
||||
health: 2,
|
||||
fire_ticks: 73,
|
||||
thrower: Some([5, 6, 7, 8]),
|
||||
target: None,
|
||||
id: 7,
|
||||
stack: ItemStack {
|
||||
item: Arc::from("minecraft:diamond_pickaxe"),
|
||||
@@ -2166,6 +2326,41 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and
|
||||
/// `TropicalFish` read; a missing tag is the default variant (a medium
|
||||
/// salmon, otherwise index 0).
|
||||
fn read_variant(m: &mut Mob, tag: &Tag) {
|
||||
use crate::mobs::Species as S;
|
||||
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,
|
||||
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
|
||||
S::Rabbit => {
|
||||
m.extra.variant = match int("RabbitType") {
|
||||
v @ (0..=5 | 99) => v as u8,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
S::Salmon => {
|
||||
m.extra.variant = text(tag.get("type"))
|
||||
.and_then(|t| SALMON_SIZES.iter().position(|v| *v == t))
|
||||
.unwrap_or(1) as u8;
|
||||
}
|
||||
S::TropicalFish => {
|
||||
let packed = int("Variant");
|
||||
let (size, index) = (packed & 0xFF, (packed >> 8) & 0xFF);
|
||||
// TropicalFish.Pattern.byId: an unknown id is a kob.
|
||||
m.extra.variant = if size <= 1 && index < 6 {
|
||||
(size * 6 + index) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
m.extra.variant2 = ((packed >> 16) & 15) as u8;
|
||||
m.extra.variant3 = ((packed >> 24) & 15) as u8;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
|
||||
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
|
||||
let ttl = memories
|
||||
|
||||
@@ -26,6 +26,13 @@ pub enum ItemPose {
|
||||
Bow {
|
||||
ticks_used: f32,
|
||||
},
|
||||
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
|
||||
/// the ticks since its last hit's feedback.
|
||||
Spear {
|
||||
kinetic: crate::spear::Kinetic,
|
||||
time_held: f32,
|
||||
ticks_since_hit: f32,
|
||||
},
|
||||
Crossbow {
|
||||
ticks_used: Option<f32>,
|
||||
charge_duration: f32,
|
||||
@@ -69,6 +76,16 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
if matches!(pose, ItemPose::Using) {
|
||||
return m;
|
||||
}
|
||||
// The spear's own place in the hand, without the equip height.
|
||||
if let ItemPose::Spear {
|
||||
kinetic,
|
||||
time_held,
|
||||
ticks_since_hit,
|
||||
} = pose
|
||||
{
|
||||
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
|
||||
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
|
||||
}
|
||||
if matches!(pose, ItemPose::SpinAttack) {
|
||||
return m
|
||||
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
|
||||
|
||||
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
/// The camera entity is a spectator (`LavaFogEnvironment`).
|
||||
pub spectator: bool,
|
||||
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
|
||||
pub fire_resistance: bool,
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water { water_vision } else { 0.0 };
|
||||
let brighten = if eye_in_water {
|
||||
water_vision
|
||||
} else {
|
||||
input.night_vision
|
||||
};
|
||||
if color.iter().all(|&c| c != 0.0) {
|
||||
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
|
||||
color = color.map(|c| c + (c * scale - c) * brighten);
|
||||
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
};
|
||||
match input.fog_type {
|
||||
FogType::Lava => {
|
||||
fog.environmental_start = 0.25;
|
||||
fog.environmental_end = 1.0;
|
||||
fog.sky_end = 1.0;
|
||||
fog.cloud_end = 1.0;
|
||||
// LavaFogEnvironment.setupFog.
|
||||
(fog.environmental_start, fog.environmental_end) = if input.spectator {
|
||||
(-8.0, rd * 0.5)
|
||||
} else if input.fire_resistance {
|
||||
(0.0, 5.0)
|
||||
} else {
|
||||
(0.25, 1.0)
|
||||
};
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
FogType::PowderSnow => {
|
||||
fog.environmental_start = 0.0;
|
||||
@@ -405,6 +422,9 @@ mod tests {
|
||||
darkness: None,
|
||||
darken_world: 0.0,
|
||||
boss_world_fog: false,
|
||||
spectator: false,
|
||||
fire_resistance: false,
|
||||
night_vision: 0.0,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -889,12 +889,17 @@ impl Font {
|
||||
if s.contains('§') {
|
||||
return self.width_text(&Text::legacy(s));
|
||||
}
|
||||
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
|
||||
crate::bidi::visual_str(s)
|
||||
.chars()
|
||||
.map(|c| self.glyph(c).advance)
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// Font.width of a component: `§` codes in the spans are formatting,
|
||||
/// not glyphs.
|
||||
/// not glyphs. Right-to-left text is measured as it is drawn, shaped.
|
||||
pub fn width_text(&self, t: &Text) -> f32 {
|
||||
t.resolved()
|
||||
let t = t.resolved();
|
||||
crate::bidi::visual(&t)
|
||||
.chars()
|
||||
.map(|(c, s)| self.advance(c, s))
|
||||
.sum::<f32>()
|
||||
|
||||
+724
-166
File diff suppressed because it is too large.
Load diff
@@ -21,6 +21,9 @@ pub(super) struct Toast {
|
||||
frame: Frame,
|
||||
title: Text,
|
||||
slide: Slide,
|
||||
/// `ToastInstance.firstSlotIndex`: the 32-pixel slot it stacks in once it
|
||||
/// leaves the queue.
|
||||
slot: Option<usize>,
|
||||
/// A `RecipeToast`: the crafting station and result of each display
|
||||
/// unlocked while it lasts (`recipeItems`); empty for an advancement.
|
||||
recipes: Vec<(String, ItemStack)>,
|
||||
@@ -36,6 +39,7 @@ impl Toast {
|
||||
frame: Frame::Task,
|
||||
title: Text::literal(""),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: items,
|
||||
changed: true,
|
||||
last_changed: 0,
|
||||
@@ -524,6 +528,7 @@ impl Game<'_> {
|
||||
frame: d.frame,
|
||||
title: text_of(&d.title),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: Vec::new(),
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
@@ -546,10 +551,17 @@ impl Game<'_> {
|
||||
.announced
|
||||
.push(Text::translate(key, &[player, name]));
|
||||
}
|
||||
None => self.system_message(Text::translate(
|
||||
key,
|
||||
&[Text::literal(super::chat::PLAYER_NAME), name],
|
||||
)),
|
||||
None => {
|
||||
let text = Text::translate(key, &[self.player_display_text(), name]);
|
||||
// The guests hear of the host's advancement too.
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub
|
||||
.system_message(horizon_client::lan::data::component_nbt(
|
||||
&text.component(),
|
||||
));
|
||||
}
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -806,58 +818,105 @@ impl Game<'_> {
|
||||
c["from"].as_str().is_none_or(|f| f == from) && c["to"].as_str().is_none_or(|t| t == to)
|
||||
});
|
||||
}
|
||||
/// `ToastManager` with the `AdvancementToast` and the `RecipeToast`,
|
||||
/// one at a time.
|
||||
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch) {
|
||||
/// `ToastManager.SLOT_COUNT`.
|
||||
pub(super) const TOAST_SLOTS: usize = 5;
|
||||
/// `ToastManager.update` and `extractRenderState` for the `AdvancementToast`s and
|
||||
/// `RecipeToast`s: up to five stack in 32-pixel slots, the rest queue. `hidden` (F1)
|
||||
/// keeps them timing out without drawing them.
|
||||
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch, hidden: bool) {
|
||||
let multiplier = f64::from(self.settings.notification_display_time);
|
||||
let Some(t) = self.adv.toasts.front_mut() else {
|
||||
return;
|
||||
};
|
||||
let first = t.slide.origin.is_none();
|
||||
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
|
||||
let now = origin.elapsed().as_millis() as i64;
|
||||
let mut slide = std::mem::take(&mut t.slide);
|
||||
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
|
||||
t.slide = slide;
|
||||
if finished {
|
||||
self.adv.toasts.pop_front();
|
||||
let mut sound_played = false;
|
||||
let mut i = 0;
|
||||
while i < self.adv.toasts.len() {
|
||||
let t = &mut self.adv.toasts[i];
|
||||
if t.slot.is_none() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
|
||||
let now = origin.elapsed().as_millis() as i64;
|
||||
let mut slide = std::mem::take(&mut t.slide);
|
||||
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
|
||||
t.slide = slide;
|
||||
if finished {
|
||||
self.adv.toasts.remove(i);
|
||||
continue;
|
||||
}
|
||||
// Only the first visibility change of an update plays its sound.
|
||||
if let Some(show) = sound
|
||||
&& !std::mem::replace(&mut sound_played, true)
|
||||
{
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// The queued toasts take the free slots in order; a challenge's own sound
|
||||
// plays once as it leaves the queue.
|
||||
let mut used = self.toast_slots_used();
|
||||
let mut challenge_sound = false;
|
||||
for t in &mut self.adv.toasts {
|
||||
if t.slot.is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(slot) = (0..Self::TOAST_SLOTS).find(|s| used & (1 << s) == 0) else {
|
||||
break;
|
||||
};
|
||||
used |= 1 << slot;
|
||||
t.slot = Some(slot);
|
||||
challenge_sound |= !t.is_recipe() && t.frame == Frame::Challenge;
|
||||
}
|
||||
if challenge_sound {
|
||||
self.audio
|
||||
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
|
||||
}
|
||||
if hidden {
|
||||
return;
|
||||
}
|
||||
for i in 0..self.adv.toasts.len() {
|
||||
if let Some(slot) = self.adv.toasts[i].slot {
|
||||
self.draw_toast(batch, i, 32.0 * slot as f32, multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The slots held by shown advancement, recipe and pack toasts, as bits.
|
||||
pub(super) fn toast_slots_used(&self) -> u32 {
|
||||
let packs = self.server_packs.toast_slots();
|
||||
self.adv
|
||||
.toasts
|
||||
.iter()
|
||||
.filter_map(|t| t.slot)
|
||||
.chain(packs)
|
||||
.fold(0, |used, slot| used | 1 << slot)
|
||||
}
|
||||
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
|
||||
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
|
||||
let t = &self.adv.toasts[index];
|
||||
let x = batch.width - 160.0 * t.slide.portion;
|
||||
let fully_for = t.slide.fully_for;
|
||||
let (title, frame, icon) = (t.title.clone(), t.frame, t.icon.clone());
|
||||
let recipe = t
|
||||
.is_recipe()
|
||||
.then(|| t.recipes[t.displayed_recipe(fully_for, multiplier)].clone());
|
||||
// ToastManager.update: the sound of the new visibility, and a
|
||||
// challenge's own sound as it leaves the queue.
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
if first && recipe.is_none() && frame == Frame::Challenge {
|
||||
self.audio
|
||||
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
|
||||
}
|
||||
if let Some((station, item)) = recipe {
|
||||
// RecipeToast: black description under a purple title, the
|
||||
// station at 0.6 scale in the corner over the cycling result.
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/recipe", x, 0.0);
|
||||
batch.sprite(atlas, "gui/sprites/toast/recipe", x, y);
|
||||
let title = self.translate("recipe.toast.title", "New Recipe(s) Unlocked!");
|
||||
let description = self.translate("recipe.toast.description", "Check your recipe book");
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.text(atlas, &title, x + 30.0, 7.0, 0xFF50_0050, false);
|
||||
batch.text(atlas, &description, x + 30.0, 18.0, 0xFF00_0000, false);
|
||||
batch.text(atlas, &title, x + 30.0, y + 7.0, 0xFF50_0050, false);
|
||||
batch.text(atlas, &description, x + 30.0, y + 18.0, 0xFF00_0000, false);
|
||||
let start = batch.vertices.len();
|
||||
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, 3.0);
|
||||
batch.scale_since(start, x, 0.0, 0.6, 0.6);
|
||||
self.draw_item_model(batch, &item, x + 8.0, 8.0);
|
||||
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, y + 3.0);
|
||||
batch.scale_since(start, x, y, 0.6, 0.6);
|
||||
self.draw_item_model(batch, &item, x + 8.0, y + 8.0);
|
||||
return;
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
|
||||
let (key, color) = match frame {
|
||||
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
|
||||
Frame::Goal => ("advancements.toast.goal", 0xFF00_FFFF),
|
||||
@@ -867,12 +926,12 @@ impl Game<'_> {
|
||||
atlas,
|
||||
&Text::translate(key, &[]),
|
||||
x + 30.0,
|
||||
7.0,
|
||||
y + 7.0,
|
||||
color,
|
||||
false,
|
||||
);
|
||||
batch.text_styled(atlas, &title, x + 30.0, 18.0, u32::MAX, false);
|
||||
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, 8.0);
|
||||
batch.text_styled(atlas, &title, x + 30.0, y + 18.0, u32::MAX, false);
|
||||
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
|
||||
}
|
||||
}
|
||||
/// `Advancement.name`: the bracketed title in the frame's colour, with the
|
||||
@@ -1053,6 +1112,7 @@ impl Game<'_> {
|
||||
frame: d.frame,
|
||||
title: text_of(&d.title),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: Vec::new(),
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
|
||||
@@ -8,7 +8,6 @@ use horizon_core::random::RandomSource;
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_allays(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
let player = self.player.as_ref().map(|p| p.position);
|
||||
// The playing jukeboxes, found at the first allay: no allay starts
|
||||
// or stops one, and the block entities run into the thousands.
|
||||
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
|
||||
@@ -58,7 +57,7 @@ impl Game<'_> {
|
||||
};
|
||||
let Some((j, _)) = wanted else {
|
||||
self.mobs[i].extra.point = None;
|
||||
self.allay_deliver(i, player);
|
||||
self.allay_deliver(i);
|
||||
continue;
|
||||
};
|
||||
let e = &self.dropped[j];
|
||||
@@ -90,14 +89,46 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
|
||||
/// found by UUID among the host and the guests of the level (the window
|
||||
/// is a guest on the server thread), when they are not a spectator and
|
||||
/// within 64 blocks.
|
||||
fn allay_liked_player(&self, i: usize) -> Option<horizon_core::geometry::Vec3> {
|
||||
let m = &self.mobs[i];
|
||||
if !m.extra.tame {
|
||||
return None;
|
||||
}
|
||||
// An allay saved before its player was kept belongs to the host.
|
||||
let uuid = m
|
||||
.extra
|
||||
.owner
|
||||
.filter(|u| *u != [0, 0, 0, 1])
|
||||
.or(self.level.player_uuid)
|
||||
.unwrap_or([0, 0, 0, 1]);
|
||||
let host = self
|
||||
.player_view()
|
||||
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
|
||||
.map(|p| p.position);
|
||||
let found = host.or_else(|| {
|
||||
self.lan_present()
|
||||
.into_iter()
|
||||
.find(|p| p.uuid == uuid && !p.spectator)
|
||||
.map(|p| {
|
||||
horizon_core::geometry::Vec3::new(p.position[0], p.position[1], p.position[2])
|
||||
})
|
||||
});
|
||||
found.filter(|p| p.distance_squared(m.position) < 64.0 * 64.0)
|
||||
}
|
||||
/// `GoAndGiveItemsToTarget` / `AllayAi.throwItem`: within 3 blocks of
|
||||
/// the deposit, one item a tick, each restarting the three-second pause
|
||||
/// before it picks anything up again.
|
||||
fn allay_deliver(&mut self, i: usize, player: Option<horizon_core::geometry::Vec3>) {
|
||||
fn allay_deliver(&mut self, i: usize) {
|
||||
let m = &self.mobs[i];
|
||||
if m.extra.ints[1] <= 0 {
|
||||
return;
|
||||
}
|
||||
let player = self.allay_liked_player(i);
|
||||
let m = &self.mobs[i];
|
||||
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
|
||||
Some(h) => horizon_core::geometry::Vec3::new(
|
||||
f64::from(h[0]) + 0.5,
|
||||
@@ -166,11 +197,14 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
return true;
|
||||
}
|
||||
let acting = self.acting_uuid();
|
||||
let holds = m.extra.main_hand.is_some();
|
||||
if !holds && !item.is_empty() {
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.main_hand = Some(item.into());
|
||||
m.extra.tame = true;
|
||||
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
|
||||
m.extra.owner = Some(acting);
|
||||
self.audio
|
||||
.play("minecraft:entity.allay.item_given", Some(at), 2.0, 1.0);
|
||||
self.consume_held();
|
||||
@@ -185,6 +219,7 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| "minecraft:air".into());
|
||||
let carried = std::mem::take(&mut m.extra.ints[1]);
|
||||
m.extra.tame = false;
|
||||
m.extra.owner = None;
|
||||
self.audio
|
||||
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
|
||||
if carried > 0 {
|
||||
|
||||
@@ -627,21 +627,8 @@ impl Game<'_> {
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let mut poses = Poses::new(model);
|
||||
// ArmorStand.DEFAULT_*_POSE and ArmorStandModel part visibility.
|
||||
let deg = |v: f32| v.to_radians();
|
||||
for (name, rot) in [
|
||||
("left_arm", [-10.0, 0.0, -10.0]),
|
||||
("right_arm", [-15.0, 0.0, 10.0]),
|
||||
("left_leg", [-1.0, 0.0, -1.0]),
|
||||
("right_leg", [1.0, 0.0, 1.0]),
|
||||
] {
|
||||
if poses.has(name) {
|
||||
let pose = poses.get(name);
|
||||
pose[3] = deg(rot[0]);
|
||||
pose[4] = deg(rot[1]);
|
||||
pose[5] = deg(rot[2]);
|
||||
}
|
||||
}
|
||||
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
|
||||
apply_pose(&mut poses, &a.pose);
|
||||
// Item layers pose from the hidden arms too (`visible` only skips drawing).
|
||||
let posed = poses.clone();
|
||||
let mut hide = |name: &str| {
|
||||
@@ -711,7 +698,9 @@ impl Game<'_> {
|
||||
let color = undyed.map_or(0xFFFF_FFFF, |c| {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
let layer_poses = Poses::new(part);
|
||||
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
|
||||
let mut layer_poses = Poses::new(part);
|
||||
apply_pose(&mut layer_poses, &a.pose);
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
@@ -743,6 +732,33 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The `Pose` rotations on the parts of an armor stand model or its armor
|
||||
/// (`ArmorStandArmorModel.setupAnim`); the body also turns the sticks.
|
||||
fn apply_pose(poses: &mut Poses, pose: &[[f32; 3]; 6]) {
|
||||
let parts: [&[&str]; 6] = [
|
||||
&["head"],
|
||||
&[
|
||||
"body",
|
||||
"right_body_stick",
|
||||
"left_body_stick",
|
||||
"shoulder_stick",
|
||||
],
|
||||
&["left_arm"],
|
||||
&["right_arm"],
|
||||
&["left_leg"],
|
||||
&["right_leg"],
|
||||
];
|
||||
for (names, rotation) in parts.into_iter().zip(pose) {
|
||||
for name in names {
|
||||
if poses.has(name) {
|
||||
let part = poses.get(name);
|
||||
part[3] = rotation[0].to_radians();
|
||||
part[4] = rotation[1].to_radians();
|
||||
part[5] = rotation[2].to_radians();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn overlaps(a: Aabb, b: Aabb) -> bool {
|
||||
let (a0, a1, b0, b1) = (
|
||||
a.min_position(),
|
||||
|
||||
@@ -547,7 +547,7 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
/// `Entity.isInRain`: rain at the block position or at the top of the box.
|
||||
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
|
||||
pub(super) fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
|
||||
let p = BlockPos::new(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
//! placed item gives its block entity.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_item, block_entity_nbt, containers::BlockEntity, item_data, items::ItemStack, save,
|
||||
Result, block_entity_item, block_entity_nbt, containers::BlockEntity, item_data,
|
||||
items::ItemStack, save,
|
||||
};
|
||||
use horizon_world::{
|
||||
nbt::{Compound, Tag},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
|
||||
/// The component patch of `stack`.
|
||||
fn components(stack: &ItemStack) -> Compound {
|
||||
@@ -77,6 +81,42 @@ impl Game<'_> {
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
|
||||
/// `block_state` component (a hive's honey level, a statue's pose) set on
|
||||
/// the block just placed at `pos`. Returns the state it has now.
|
||||
pub(super) fn apply_item_block_state(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
stack: &ItemStack,
|
||||
) -> Result<BlockStateId> {
|
||||
let Some(Tag::Compound(saved)) =
|
||||
stack.data().and_then(|d| d.other("minecraft:block_state"))
|
||||
else {
|
||||
return Ok(state);
|
||||
};
|
||||
let Some(current) = self.pack.assets.registry.state(state) else {
|
||||
return Ok(state);
|
||||
};
|
||||
// Only the properties the block has.
|
||||
let props: Vec<(String, String)> = saved
|
||||
.iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|(k, v)| current.properties.get(k).is_some_and(|old| old != v))
|
||||
.collect();
|
||||
let props: Vec<(&str, &str)> = props.iter().map(|(k, v)| (&**k, &**v)).collect();
|
||||
match self.with_props(state, &props) {
|
||||
Some(new) if !props.is_empty() && new != state => {
|
||||
self.level_set(pos, new)?;
|
||||
Ok(new)
|
||||
}
|
||||
_ => Ok(state),
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockItem.updateCustomBlockEntityTag` and `updateBlockEntityComponents`
|
||||
/// for the block entity just placed at `pos` from `stack`; `game_master`:
|
||||
/// the placer can use game master blocks (a dispenser can't).
|
||||
|
||||
@@ -1077,7 +1077,12 @@ impl Game<'_> {
|
||||
BLACK,
|
||||
);
|
||||
}
|
||||
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
|
||||
// BookSignScreen: `owner.getName()`, the account's name on a server.
|
||||
let owner = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map_or(super::chat::PLAYER_NAME, |r| r.profile_name.as_str());
|
||||
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", owner);
|
||||
let tw = HudBatch::text_width(atlas, &by);
|
||||
batch.text(
|
||||
atlas,
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
|
||||
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
|
||||
//! entity (like a decorated pot's) until the first counted stroke rolls it.
|
||||
use super::{Game, Hit, decorated_pot, spill, view_vector};
|
||||
use horizon_client::{
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `BrushItem.USE_DURATION`.
|
||||
const USE_DURATION: u32 = 200;
|
||||
|
||||
/// A brushable block's counters. Vanilla never saves them: the block's
|
||||
/// `dusted` state is all that is left after a reload.
|
||||
#[derive(Default)]
|
||||
pub(super) struct BrushState {
|
||||
count: i32,
|
||||
resets_at: i64,
|
||||
cooldown_ends_at: i64,
|
||||
hit_face: Option<[i32; 3]>,
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.getCompletionState`: the `dusted` property.
|
||||
fn completion_state(count: i32) -> i32 {
|
||||
match count {
|
||||
0 => 0,
|
||||
1..=2 => 1,
|
||||
3..=5 => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
|
||||
/// hand of `slot` acting.
|
||||
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
|
||||
// No block under the crosshair: releaseUsingItem.
|
||||
let Some(hit) = self.pick() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
let elapsed = ticks + 1;
|
||||
// The last tick before the backswing.
|
||||
if elapsed % 10 == 5 {
|
||||
self.brush_stroke(&hit, slot);
|
||||
}
|
||||
// The use ends after 200 ticks; the held button starts it again.
|
||||
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
|
||||
}
|
||||
|
||||
/// One stroke on the block aimed at: dust (this window only), the brush
|
||||
/// sound, and the block entity's count. The brush wears when the block
|
||||
/// changed state.
|
||||
fn brush_stroke(&mut self, hit: &Hit, slot: usize) {
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let (sound, brushable) = match &*name {
|
||||
"minecraft:suspicious_sand" => ("minecraft:item.brush.brushing.sand", true),
|
||||
"minecraft:suspicious_gravel" => ("minecraft:item.brush.brushing.gravel", true),
|
||||
_ => ("minecraft:item.brush.brushing.generic", false),
|
||||
};
|
||||
if self.lan_acting.is_none() {
|
||||
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
|
||||
}
|
||||
let at = hit.pos.map(|v| f64::from(v) + 0.5);
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
if brushable && self.brush_block(hit.pos, hit.face) {
|
||||
self.damage_held(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrushItem.spawnDustParticles`.
|
||||
pub(super) fn brush_dust(&mut self, hit: &Hit, off_hand: bool) {
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, hit.pos) else {
|
||||
return;
|
||||
};
|
||||
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
|
||||
// The arm that brushes: the main arm, or the other one for the off hand.
|
||||
let flip = if off_hand == self.settings.main_hand_left {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
let view = view_vector(self.yaw, self.pitch);
|
||||
// DustParticlesDelta.fromDirection.
|
||||
let (dx, dz) = match hit.face {
|
||||
[0, _, 0] => (view[2], -view[0]),
|
||||
[0, 0, -1] => (1.0, -0.1),
|
||||
[0, 0, 1] => (-1.0, 0.1),
|
||||
[-1, 0, 0] => (-0.1, -1.0),
|
||||
_ => (0.1, 1.0),
|
||||
};
|
||||
let count = 7 + self.particles.level_random().next_int_bound(5).unwrap_or(0);
|
||||
let x = hit.point[0] - if hit.face == [-1, 0, 0] { 1.0e-6 } else { 0.0 };
|
||||
let z = hit.point[2] - if hit.face == [0, 0, -1] { 1.0e-6 } else { 0.0 };
|
||||
for _ in 0..count {
|
||||
let random = self.particles.level_random();
|
||||
let v = [
|
||||
dx * flip * 3.0 * random.next_double(),
|
||||
0.0,
|
||||
dz * flip * 3.0 * random.next_double(),
|
||||
];
|
||||
self.particles.add(&options, [x, hit.point[1], z], v);
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
|
||||
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
|
||||
let time = self.world.game_time;
|
||||
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
|
||||
brush.hit_face.get_or_insert(face);
|
||||
brush.resets_at = time + 40;
|
||||
if time < brush.cooldown_ends_at {
|
||||
self.brushing.insert(pos, brush);
|
||||
return false;
|
||||
}
|
||||
brush.cooldown_ends_at = time + 10;
|
||||
self.unpack_brush_loot(pos);
|
||||
let before = completion_state(brush.count);
|
||||
brush.count += 1;
|
||||
if brush.count >= 10 {
|
||||
self.brushing_completed(pos, brush.hit_face);
|
||||
return true;
|
||||
}
|
||||
self.schedule_brushable_tick(pos);
|
||||
let after = completion_state(brush.count);
|
||||
self.brushing.insert(pos, brush);
|
||||
if before != after {
|
||||
self.set_dusted(pos, after);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
|
||||
/// block entity's item.
|
||||
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
|
||||
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
|
||||
return;
|
||||
};
|
||||
if tag.get("LootTable").is_none() {
|
||||
return;
|
||||
}
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
decorated_pot::unpack_loot(
|
||||
&mut tag,
|
||||
loot.as_deref(),
|
||||
&mut self.world.random,
|
||||
&mut self.rand_sequences,
|
||||
self.level.seed,
|
||||
);
|
||||
spill::put_saved_entity(&mut self.world, pos, tag);
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
|
||||
/// brushed face and the block turns into its sand or gravel.
|
||||
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let turns_into = match &*self.res.facts().get(state).name {
|
||||
"minecraft:suspicious_sand" => "minecraft:sand",
|
||||
"minecraft:suspicious_gravel" => "minecraft:gravel",
|
||||
_ => "minecraft:air",
|
||||
};
|
||||
// dropContent: the item at the middle of the block beside that face.
|
||||
let item = spill::saved_entity(&self.world, pos)
|
||||
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
|
||||
if let Some(mut item) = item {
|
||||
let face = face.unwrap_or([0, 1, 0]);
|
||||
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
|
||||
item.count = item.count.min(take as u32);
|
||||
let at = [
|
||||
f64::from(pos[0] + face[0]) + 0.5,
|
||||
f64::from(pos[1] + face[1]) + 0.625,
|
||||
f64::from(pos[2] + face[2]) + 0.5,
|
||||
];
|
||||
self.spawn_item(item, at, [0.0; 3], 0);
|
||||
}
|
||||
// Level event 3008 for every player, then the block change.
|
||||
self.level_event_at(3008, pos, state.0 as i32);
|
||||
if let Some(base) = self.pack.assets.registry.default_state(turns_into) {
|
||||
let _ = self.level_set(pos, base);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_dusted(&mut self, pos: [i32; 3], dusted: i32) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
state.properties.insert("dusted".into(), dusted.to_string());
|
||||
if let Ok(state) = registry.resolve(&state) {
|
||||
let _ = self.level_set(pos, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_brushable_tick(&mut self, pos: [i32; 3]) {
|
||||
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.schedule_tick(level, ticks, at, 2));
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.checkReset`, from `BrushableBlock.tick`: a block
|
||||
/// left alone loses two counts, again every four ticks.
|
||||
pub(super) fn brushable_tick(&mut self, pos: [i32; 3]) {
|
||||
let time = self.world.game_time;
|
||||
let Some(mut brush) = self.brushing.remove(&pos) else {
|
||||
return;
|
||||
};
|
||||
let before = completion_state(brush.count);
|
||||
if brush.count != 0 && time >= brush.resets_at {
|
||||
brush.count = (brush.count - 2).max(0);
|
||||
brush.resets_at = time + 4;
|
||||
}
|
||||
let after = completion_state(brush.count);
|
||||
if brush.count != 0 {
|
||||
self.brushing.insert(pos, brush);
|
||||
self.schedule_brushable_tick(pos);
|
||||
}
|
||||
if before != after {
|
||||
self.set_dusted(pos, after);
|
||||
}
|
||||
}
|
||||
|
||||
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
|
||||
/// and break particles.
|
||||
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
|
||||
let state = horizon_world::registry::BlockStateId(data as u32);
|
||||
let sound = match &*self.res.facts().get(state).name {
|
||||
"minecraft:suspicious_sand" => "minecraft:item.brush.brushing.sand.complete",
|
||||
"minecraft:suspicious_gravel" => "minecraft:item.brush.brushing.gravel.complete",
|
||||
_ => "",
|
||||
};
|
||||
if !sound.is_empty() {
|
||||
let at = pos.map(|v| f64::from(v) + 0.5);
|
||||
// Each client plays it from the event itself.
|
||||
self.audio.play_unshared(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.destroy_particles(pos, state);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,60 @@
|
||||
//! shovels) put on a lit campfire puts it out.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::chunk::BlockState;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The lit campfire at `pos` that `held` puts out, when the item is in
|
||||
/// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`).
|
||||
fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option<BlockState> {
|
||||
held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:douses_campfires")
|
||||
.is_some_and(|members| members.contains(&*s.item))
|
||||
})?;
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// `CampfireBlock.useItemOn` as this client runs it before the server's
|
||||
/// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of
|
||||
/// `CampfireBlock.douse` and the campfire gone out, kept as predicted
|
||||
/// under `seq`. The level event's sound comes from the server. True when
|
||||
/// it doused (SUCCESS), which ends the click for both hands.
|
||||
pub(super) fn predict_campfire_douse(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
seq: i32,
|
||||
) -> bool {
|
||||
// Player.isSecondaryUseActive: a held item means both hands count.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return false;
|
||||
}
|
||||
let Some(mut state) = self.campfire_to_douse(pos, held) else {
|
||||
return false;
|
||||
};
|
||||
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
|
||||
for _ in 0..20 {
|
||||
make_campfire_smoke(&mut self.particles, pos, signal, true);
|
||||
}
|
||||
state.properties.insert("lit".into(), "false".into());
|
||||
if let Ok(unlit) = self.pack.assets.registry.resolve(&state) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.predict_blocks(&vec![(at, unlit)], seq);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
|
||||
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
|
||||
pub(super) fn use_campfire_douse(
|
||||
@@ -11,24 +63,11 @@ impl Game<'_> {
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> Result<bool> {
|
||||
let Some(tool) = held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:douses_campfires")
|
||||
.is_some_and(|members| members.contains(&*s.item))
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held))
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
|
||||
self.level_event_at(1009, pos, 0);
|
||||
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
|
||||
@@ -44,7 +83,6 @@ impl Game<'_> {
|
||||
if let Ok(unlit) = registry.resolve(&state) {
|
||||
self.level_set(pos, unlit)?;
|
||||
}
|
||||
let used = tool.clone();
|
||||
self.damage_held(1);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &used);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! `CatSpawner`: every 1200 ticks a cat may appear near a random player,
|
||||
//! in a village with enough occupied beds or inside a swamp hut.
|
||||
use super::{
|
||||
Dim, Game,
|
||||
raids::{section, sections_to_village},
|
||||
};
|
||||
use horizon_client::mobs::{Species, spawn_rules};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
const TICK_DELAY: i32 = 1200;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CatSpawner.tick` (the overworld's custom spawner).
|
||||
pub(super) fn tick_cat_spawner(&mut self) {
|
||||
if self.dim != Dim::Overworld {
|
||||
return;
|
||||
}
|
||||
self.creatures.cat_tick -= 1;
|
||||
if self.creatures.cat_tick > 0 {
|
||||
return;
|
||||
}
|
||||
self.creatures.cat_tick = TICK_DELAY;
|
||||
// ServerLevel.getRandomPlayer: one of the living players.
|
||||
let alive: Vec<Vec3> = self
|
||||
.level_players()
|
||||
.into_iter()
|
||||
.filter(|p| p.alive)
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
if alive.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(alive.len() as i32)
|
||||
.unwrap_or(0);
|
||||
let p = alive[pick as usize];
|
||||
let mut offset = || {
|
||||
let n = 8 + self.world.random.next_int_bound(24).unwrap_or(0);
|
||||
if self.world.random.next_boolean() {
|
||||
-n
|
||||
} else {
|
||||
n
|
||||
}
|
||||
};
|
||||
let x = offset();
|
||||
let z = offset();
|
||||
let pos = [
|
||||
p.x.floor() as i32 + x,
|
||||
p.y.floor() as i32,
|
||||
p.z.floor() as i32 + z,
|
||||
];
|
||||
// hasChunksAt(pos +- 10)
|
||||
let loaded = ((pos[0] - 10) >> 4..=(pos[0] + 10) >> 4).all(|cx| {
|
||||
((pos[2] - 10) >> 4..=(pos[2] + 10) >> 4)
|
||||
.all(|cz| self.world.chunks.contains_key(&(cx, cz)))
|
||||
});
|
||||
if !loaded {
|
||||
return;
|
||||
}
|
||||
let ok = spawn_rules::is_spawn_position_ok(
|
||||
Species::Cat,
|
||||
&self.mob_world_base(&[]),
|
||||
pos[0],
|
||||
pos[1],
|
||||
pos[2],
|
||||
);
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
if sections_to_village(&self.village_centers(), section(pos)) <= 2 {
|
||||
self.spawn_cat_in_village(pos);
|
||||
} else {
|
||||
let hut = self.structure_tag("minecraft:cats_spawn_in");
|
||||
if self.structures_at(pos).iter().any(|s| hut.contains(s)) {
|
||||
self.spawn_cat_in_hut(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `spawnInVillage`: more than four occupied beds within 48 blocks and
|
||||
/// fewer than five cats around.
|
||||
fn spawn_cat_in_village(&mut self, pos: [i32; 3]) {
|
||||
// PoiManager.getCountInRange(HOME, IS_OCCUPIED): beds villagers have claimed.
|
||||
let mut beds: Vec<[i32; 3]> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Villager && m.alive())
|
||||
.filter_map(|m| m.extra.bed)
|
||||
.filter(|b| {
|
||||
let d = |i: usize| i64::from(b[i] - pos[i]);
|
||||
d(0).abs() <= 48
|
||||
&& d(2).abs() <= 48
|
||||
&& d(0).pow(2) + d(1).pow(2) + d(2).pow(2) <= 48 * 48
|
||||
})
|
||||
.collect();
|
||||
beds.sort_unstable();
|
||||
beds.dedup();
|
||||
if beds.len() > 4 && self.cats_near(pos, 48.0) < 5 {
|
||||
self.spawn_cat(pos, false);
|
||||
}
|
||||
}
|
||||
/// `spawnInHut`: no cat within 16 blocks; the cat stays.
|
||||
fn spawn_cat_in_hut(&mut self, pos: [i32; 3]) {
|
||||
if self.cats_near(pos, 16.0) == 0 {
|
||||
self.spawn_cat(pos, true);
|
||||
}
|
||||
}
|
||||
/// Cats in `AABB(pos).inflate(radius, 8, radius)`.
|
||||
fn cats_near(&self, pos: [i32; 3], radius: f64) -> usize {
|
||||
let [x, y, z] = pos.map(f64::from);
|
||||
let min = Vec3::new(x - radius, y - 8.0, z - radius);
|
||||
let max = Vec3::new(x + 1.0 + radius, y + 9.0, z + 1.0 + radius);
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.species() == Species::Cat && m.alive() && m.bounds().intersects_corners(min, max)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
/// `spawnCat`: finalized for a natural spawn, black inside
|
||||
/// `#cats_spawn_as_black` structures (`Cat.finalizeSpawn`).
|
||||
fn spawn_cat(&mut self, pos: [i32; 3], persistent: bool) {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let Some(id) = self.spawn_mob("minecraft:cat", at, persistent) else {
|
||||
return;
|
||||
};
|
||||
let black = self.structure_tag("minecraft:cats_spawn_as_black");
|
||||
let in_black = self.structures_at(pos).iter().any(|s| black.contains(s));
|
||||
if in_black && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.extra.variant = 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,8 @@ pub struct UiState {
|
||||
pub chat_screen: Option<ChatScreen>,
|
||||
/// When the chat screen opened (its input took the focus: the cursor blink).
|
||||
pub chat_opened: Option<std::time::Instant>,
|
||||
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
|
||||
pub in_bed_chat: bool,
|
||||
pub registries: Registries,
|
||||
pub rules: GameRules,
|
||||
pub clock: WorldClock,
|
||||
@@ -102,10 +104,25 @@ pub struct UiState {
|
||||
/// Death screen cause and when the player died (buttons wait 20 ticks).
|
||||
pub death_text: Option<Text>,
|
||||
pub died_at: Option<std::time::Instant>,
|
||||
/// `DeathScreen.TitleConfirmScreen` and when it opened (its buttons wait
|
||||
/// 20 ticks, `setDelay`).
|
||||
pub death_confirm: Option<std::time::Instant>,
|
||||
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
|
||||
pub death_time: u32,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// The state a window starts with: the chat remembers the commands kept
|
||||
/// in the game folder (`ChatComponent`'s constructor).
|
||||
pub fn new() -> Self {
|
||||
let mut ui = Self::default();
|
||||
if let Ok(dir) = std::env::current_dir() {
|
||||
ui.chat.load_history(&dir);
|
||||
}
|
||||
ui
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
|
||||
pub(super) fn allow_commands(&self) -> bool {
|
||||
@@ -440,6 +457,12 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
pub(super) fn close_chat(&mut self) {
|
||||
// InBedChatScreen.onClose: leaving the screen means getting up; the
|
||||
// screen goes once the player has woken (`tick_in_bed_chat`).
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
self.wake_up();
|
||||
return;
|
||||
}
|
||||
self.ui.chat_screen = None;
|
||||
self.ui.chat.reset_scroll();
|
||||
if self.screen == Screen::Chat {
|
||||
@@ -447,6 +470,35 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
|
||||
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
|
||||
/// screen if something was typed.
|
||||
pub(super) fn tick_in_bed_chat(&mut self) {
|
||||
if self.screen != Screen::Chat {
|
||||
self.ui.in_bed_chat = false;
|
||||
}
|
||||
if self.ui.in_bed_chat {
|
||||
if self.sleeping.is_none() {
|
||||
self.ui.in_bed_chat = false;
|
||||
let text = self
|
||||
.ui
|
||||
.chat_screen
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.close_chat();
|
||||
if !text.is_empty() {
|
||||
self.open_chat(&text);
|
||||
}
|
||||
}
|
||||
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
|
||||
self.open_chat("");
|
||||
self.ui.in_bed_chat = self.screen == Screen::Chat;
|
||||
}
|
||||
}
|
||||
/// The Leave Bed button of `InBedChatScreen`: its bounds.
|
||||
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
|
||||
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
|
||||
}
|
||||
/// `Minecraft.computeChatAbilities` from the Chat option.
|
||||
pub(super) fn chat_abilities(&self) -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
|
||||
@@ -559,7 +611,15 @@ impl Game<'_> {
|
||||
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
|
||||
ChatAction::Close => self.close_chat(),
|
||||
ChatAction::Submit(message) => {
|
||||
self.close_chat();
|
||||
// InBedChatScreen sends without closing (`closeOnSubmit` is
|
||||
// false): the box empties and the screen stays.
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
screen.suggest_command(&m, &c, "");
|
||||
self.ui.chat.reset_scroll();
|
||||
self.ui.chat_screen = Some(screen);
|
||||
} else {
|
||||
self.close_chat();
|
||||
}
|
||||
if !message.is_empty() {
|
||||
self.handle_chat_input(&message);
|
||||
}
|
||||
@@ -661,7 +721,8 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// ChatScreen.mouseScrolled: `hasShiftDown`, either Shift key.
|
||||
let shift = self.has_shift_down();
|
||||
let amount = amount.clamp(-1.0, 1.0) * if shift { 1.0 } else { 7.0 };
|
||||
self.ui.chat.scroll_by(amount as i32, true);
|
||||
}
|
||||
@@ -675,6 +736,16 @@ impl Game<'_> {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
let width = self.renderer.as_ref().map_or(1280.0, |r| {
|
||||
HudBatch::new([r.config.width, r.config.height]).width
|
||||
});
|
||||
let (x, y, w, h) = Self::leave_bed_button(width, height);
|
||||
if horizon_client::screen_draw::inside((cursor.0 as i32, cursor.1 as i32), x, y, w, h) {
|
||||
self.wake_up();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
if screen.click(&m, &c, cursor.0 as f64, cursor.1 as f64) {
|
||||
return;
|
||||
@@ -682,12 +753,25 @@ impl Game<'_> {
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
match self.ui.chat.click_at(
|
||||
let Some(style) = self.ui.chat.style_at(
|
||||
&self.gui.atlas.font,
|
||||
height as i32,
|
||||
cursor.0 as f64,
|
||||
cursor.1 as f64,
|
||||
) {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
// ChatScreen.handleComponentClicked: with Shift held only an
|
||||
// insertion acts, else only the click event.
|
||||
if self.has_shift_down() {
|
||||
if let Some(insertion) = style.insertion
|
||||
&& let Some(screen) = self.ui.chat_screen.as_mut()
|
||||
{
|
||||
screen.type_text(&m, &c, &insertion);
|
||||
}
|
||||
return;
|
||||
}
|
||||
match style.click {
|
||||
Some(ClickEvent::CopyToClipboard(value)) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
@@ -700,9 +784,26 @@ impl Game<'_> {
|
||||
screen.suggest_command(&m, &c, &command);
|
||||
}
|
||||
}
|
||||
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
/// `Screen.clickCommandAction`: run a clicked command as the player
|
||||
/// without adding it to the chat history. A client mod's command goes
|
||||
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
|
||||
/// commands take it. (Vanilla asks first when the command fails to
|
||||
/// parse; here the server answers it.)
|
||||
fn click_command(&mut self, command: &str) {
|
||||
let command = command.strip_prefix('/').unwrap_or(command);
|
||||
if self.mod_command(command) {
|
||||
return;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(&format!("/{command}"));
|
||||
} else if !self.local_server_mod_command(command) {
|
||||
self.run_command(command);
|
||||
}
|
||||
}
|
||||
/// Chat lines over the HUD (focused while the chat screen is open).
|
||||
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -737,17 +838,59 @@ impl Game<'_> {
|
||||
let draws = screen.draw(&m, mouse, blink, background);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
|
||||
self.ui.chat_screen = Some(screen);
|
||||
// The hovered message part's tooltip, one row per line of its text.
|
||||
if let Some(hover) = self.ui.chat.hover_at(
|
||||
&self.gui.atlas.font,
|
||||
batch.height as i32,
|
||||
f64::from(self.cursor.0),
|
||||
f64::from(self.cursor.1),
|
||||
) {
|
||||
let lines = hover_rows(&hover);
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
|
||||
let label = Text::literal(horizon_client::lang::get_or(
|
||||
"multiplayer.stopSleeping",
|
||||
"Leave Bed",
|
||||
));
|
||||
let mut button = Vec::new();
|
||||
horizon_client::screen_draw::button(
|
||||
&mut button,
|
||||
&m,
|
||||
&label,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
true,
|
||||
horizon_client::screen_draw::inside(mouse, x, y, w, h),
|
||||
);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
|
||||
}
|
||||
// The hovered message part's tooltip (`renderComponentHoverEffect`),
|
||||
// or the text of the message tag under the cursor.
|
||||
let height = batch.height as i32;
|
||||
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
|
||||
let wrap = (batch.width as i32 / 2).max(200);
|
||||
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
|
||||
if let Some(hover) = style.hover {
|
||||
let lines = self.wrapped_rows(&hover, wrap);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(stack) = style
|
||||
.hover_item
|
||||
.as_deref()
|
||||
.and_then(horizon_client::item_data::stack_from_hover)
|
||||
.filter(|s| self.res.items().get(&s.item).is_some())
|
||||
{
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
} else if self.ui.chat.indicator_at(height, cx, cy) {
|
||||
// GuiMessageTag.systemSinglePlayer's text, split at 210.
|
||||
let tag = Text::translate("chat.tag.system_single_player", &[]);
|
||||
let lines = self.wrapped_rows(&tag, 210);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
|
||||
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
|
||||
let split = self
|
||||
.gui
|
||||
.atlas
|
||||
.font
|
||||
.split(&text.resolve_legacy(), width as f32);
|
||||
split.into_iter().map(|row| (row, u32::MAX)).collect()
|
||||
}
|
||||
/// Advance the overworld clock (ServerClockManager.tick) when
|
||||
/// `advance_time` allows it.
|
||||
pub(super) fn advance_clock(&mut self) {
|
||||
@@ -801,7 +944,11 @@ impl Game<'_> {
|
||||
pub(super) fn load_world_state(&mut self) {
|
||||
self.ui.rules = GameRules::load(&self.world_root);
|
||||
self.ui.clock = WorldClock::default();
|
||||
self.ui.difficulty = read_difficulty(&self.world_root).unwrap_or(2);
|
||||
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
|
||||
self.ui.difficulty = horizon_client::title::DIFFICULTIES
|
||||
.iter()
|
||||
.position(|d| *d == self.level.difficulty)
|
||||
.map_or(2, |i| i as u8);
|
||||
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
|
||||
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
|
||||
&& let Ok((root, _)) =
|
||||
@@ -826,9 +973,9 @@ impl Game<'_> {
|
||||
self.build_registries();
|
||||
self.sync_rules();
|
||||
}
|
||||
/// Save the clock and the difficulty beside level.dat (Level.write saves
|
||||
/// the game rules: a second writer of game_rules.dat could leave it with
|
||||
/// the rules loaded when the world opened).
|
||||
/// Save the clock beside level.dat (Level.write saves the game rules: a
|
||||
/// second writer of game_rules.dat could leave it with the rules loaded
|
||||
/// when the world opened).
|
||||
pub(super) fn save_world_state(&mut self) {
|
||||
if self.world_root.as_os_str().is_empty() {
|
||||
return;
|
||||
@@ -876,9 +1023,6 @@ impl Game<'_> {
|
||||
if let Err(e) = horizon_client::save::write_dat(&path, root) {
|
||||
log::warn!("the world clocks were not written: {e}");
|
||||
}
|
||||
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
|
||||
log::warn!("the difficulty was not written: {e}");
|
||||
}
|
||||
}
|
||||
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
|
||||
/// cause is known to the client).
|
||||
@@ -886,6 +1030,7 @@ impl Game<'_> {
|
||||
let cause = self.ui.death_cause.take();
|
||||
self.ui.died_at = Some(std::time::Instant::now());
|
||||
self.ui.death_text = None;
|
||||
self.ui.death_confirm = None;
|
||||
if !self.ui.rules.bool("show_death_messages") {
|
||||
return;
|
||||
}
|
||||
@@ -900,9 +1045,19 @@ impl Game<'_> {
|
||||
None => self.combat_death_message(),
|
||||
};
|
||||
self.ui.death_text = Some(message.clone());
|
||||
// ServerPlayer.die: a team that hides death messages keeps it out of
|
||||
// the dying player's own chat (the death screen still shows it).
|
||||
if horizon_client::scoreboard::own_death_message_shown(self.player_team()) {
|
||||
// ServerPlayer.die: the team's death message visibility decides who
|
||||
// hears it, the dying player's own chat included (the death screen
|
||||
// still shows it).
|
||||
let board = &self.score.board;
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub.system_message_to(
|
||||
horizon_client::lan::data::component_nbt(&message.component()),
|
||||
|other| {
|
||||
horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, other)
|
||||
},
|
||||
);
|
||||
}
|
||||
if horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, PLAYER_NAME) {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
@@ -988,60 +1143,9 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
fn read_difficulty(world: &std::path::Path) -> Option<u8> {
|
||||
let bytes = std::fs::read(world.join("level.dat")).ok()?;
|
||||
let raw = horizon_world::compression::Compression::Gzip
|
||||
.decode(&bytes, 64 << 20)
|
||||
.ok()?;
|
||||
let (root, _) =
|
||||
horizon_world::nbt::read_named(&raw, horizon_world::nbt::Limits::default()).ok()?;
|
||||
match root.tag.get("Data")?.get("Difficulty")? {
|
||||
horizon_world::nbt::Tag::Byte(b) => Some((*b).clamp(0, 3) as u8),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Patch `Data.Difficulty` in level.dat (Level::write stores the default).
|
||||
fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::Result<()> {
|
||||
use horizon_world::{compression::Compression, nbt};
|
||||
let path = world.join("level.dat");
|
||||
let bytes = std::fs::read(&path)?;
|
||||
let raw = Compression::Gzip.decode(&bytes, 64 << 20)?;
|
||||
let (mut root, _) = nbt::read_named(&raw, nbt::Limits::default()).map_err(|e| e.to_string())?;
|
||||
if let Some(data) = root.tag.get_mut("Data")
|
||||
&& let Some(d) = data.get_mut("Difficulty")
|
||||
{
|
||||
*d = nbt::Tag::Byte(difficulty as i8);
|
||||
}
|
||||
let out = nbt::write_named(&root, nbt::Limits::default()).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, Compression::Gzip.encode(&out)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `IntegratedServer.getProfilePermissions` for the owner: `OWNER` (4) when
|
||||
/// commands are allowed, else `ALL` (0).
|
||||
fn owner_permission(allow_commands: bool) -> u8 {
|
||||
if allow_commands { 4 } else { 0 }
|
||||
}
|
||||
|
||||
/// A hover text's rows for the tooltip: split at line breaks, each in the
|
||||
/// colour of its first styled character (white without one).
|
||||
fn hover_rows(hover: &horizon_client::text::Text) -> Vec<(String, u32)> {
|
||||
let mut rows = vec![(String::new(), None)];
|
||||
for (c, style) in hover.resolve_legacy().chars() {
|
||||
if c == '\n' {
|
||||
rows.push((String::new(), None));
|
||||
continue;
|
||||
}
|
||||
let row = rows.last_mut().expect("one row");
|
||||
if row.0.is_empty() {
|
||||
row.1 = style.color;
|
||||
}
|
||||
row.0.push(c);
|
||||
}
|
||||
rows.into_iter()
|
||||
.map(|(text, color)| {
|
||||
let [_, r, g, b] = color.unwrap_or(0xFF_FFFF).to_be_bytes();
|
||||
(text, horizon_client::hud::rgba(r, g, b, 255))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Chiseled bookshelves: `ChiseledBookShelfBlock.useItemOn` / `useWithoutItem`
|
||||
//! (a book into the slot under the crosshair, or out of it) and
|
||||
//! `ChiseledBookShelfBlockEntity.updateState`, which also follows the books a
|
||||
//! hopper or dropper moved (`machines::container_changed`). The slots' books
|
||||
//! show through the block state's `slot_N_occupied`, which reaches a server's
|
||||
//! clients with the block update.
|
||||
use super::{Game, Hit, machines::dir_from_name};
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Who};
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::level::{flags, hit_slot};
|
||||
|
||||
const BLOCK: &str = "minecraft:chiseled_bookshelf";
|
||||
|
||||
impl Game<'_> {
|
||||
/// `useItemOn` with `held` in the acting hand, then `useWithoutItem` on
|
||||
/// the main hand's turn: None when the block passes the click, else
|
||||
/// whether the use succeeded and the arm swings (a click on an empty slot
|
||||
/// without a book only consumes it).
|
||||
pub(super) fn use_chiseled_bookshelf(
|
||||
&mut self,
|
||||
hit: &Hit,
|
||||
held: Option<&ItemStack>,
|
||||
) -> Option<bool> {
|
||||
let pos = hit.pos;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let info = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| s.name == BLOCK)?;
|
||||
let facing = info
|
||||
.properties
|
||||
.get("facing")
|
||||
.and_then(|f| dir_from_name(f))?;
|
||||
let face = Direction::ALL
|
||||
.into_iter()
|
||||
.find(|d| d.normal() == hit.face)?;
|
||||
let at = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
|
||||
let slot = hit_slot(
|
||||
facing,
|
||||
face,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
at,
|
||||
2,
|
||||
3,
|
||||
)?;
|
||||
let occupied = info
|
||||
.properties
|
||||
.get(&format!("slot_{slot}_occupied"))
|
||||
.map(String::as_str)
|
||||
== Some("true");
|
||||
let book = held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:bookshelf_books")
|
||||
.is_some_and(|m| m.contains(&*s.item))
|
||||
});
|
||||
let centre = pos.map(|v| f64::from(v) + 0.5);
|
||||
if let Some(book) = book
|
||||
&& !occupied
|
||||
{
|
||||
// addBook: one book goes in (a creative player keeps theirs).
|
||||
let book = book.clone();
|
||||
self.stats.add("used", &book.item, 1);
|
||||
let sound = if &*book.item == "minecraft:enchanted_book" {
|
||||
"minecraft:block.chiseled_bookshelf.insert.enchanted"
|
||||
} else {
|
||||
"minecraft:block.chiseled_bookshelf.insert"
|
||||
};
|
||||
self.bookshelf_items(pos)?[slot] = Some(ItemStack {
|
||||
count: 1,
|
||||
..book.clone()
|
||||
});
|
||||
self.consume_held();
|
||||
self.chiseled_bookshelf_update_state(pos, slot);
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &book);
|
||||
return Some(true);
|
||||
}
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
|
||||
if self.hand != horizon_client::first_person::Hand::Main {
|
||||
return None;
|
||||
}
|
||||
if !occupied {
|
||||
return Some(false);
|
||||
}
|
||||
// removeBook: the book goes to the inventory, or is dropped.
|
||||
let retrieved = self.bookshelf_items(pos)?[slot].take()?;
|
||||
self.chiseled_bookshelf_update_state(pos, slot);
|
||||
let sound = if &*retrieved.item == "minecraft:enchanted_book" {
|
||||
"minecraft:block.chiseled_bookshelf.pickup.enchanted"
|
||||
} else {
|
||||
"minecraft:block.chiseled_bookshelf.pickup"
|
||||
};
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.give_or_drop(retrieved);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// The bookshelf's six slots (made if the block has no block entity yet).
|
||||
fn bookshelf_items(&mut self, pos: [i32; 3]) -> Option<&mut [Option<ItemStack>; 6]> {
|
||||
let entity = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with((pos[0], pos[1], pos[2]), || {
|
||||
BlockEntity::ChiseledBookshelf(Box::default())
|
||||
});
|
||||
match entity {
|
||||
BlockEntity::ChiseledBookshelf(b) => Some(&mut b.items),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChiseledBookShelfBlockEntity.updateState`: `slot` becomes the last
|
||||
/// one touched, the block state takes the slots' occupancy
|
||||
/// (`setBlockAndUpdate`) and `BLOCK_CHANGE` goes out.
|
||||
fn chiseled_bookshelf_update_state(&mut self, pos: [i32; 3], slot: usize) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get_mut(&key)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
b.last_slot = slot as i32;
|
||||
let occupied: Vec<bool> = b.items.iter().map(Option::is_some).collect();
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (i, occupied) in occupied.into_iter().enumerate() {
|
||||
state
|
||||
.properties
|
||||
.insert(format!("slot_{i}_occupied"), occupied.to_string());
|
||||
}
|
||||
let Some(new) = registry.id(&state) else {
|
||||
return;
|
||||
};
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, new, flags::UPDATE_ALL));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
|
||||
/// The books a hopper or dropper put in or took out: the slot whose
|
||||
/// occupancy no longer matches the block state is the one `setItem` or
|
||||
/// `removeItem` updated.
|
||||
pub(super) fn chiseled_bookshelf_synced(&mut self, pos: (i32, i32, i32)) {
|
||||
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get(&pos) else {
|
||||
return;
|
||||
};
|
||||
let Some(info) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(pos.0, pos.1, pos.2))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let moved = (0..6).rev().find(|i| {
|
||||
let shown = info
|
||||
.properties
|
||||
.get(&format!("slot_{i}_occupied"))
|
||||
.map(String::as_str);
|
||||
b.items[*i].is_some() != (shown == Some("true"))
|
||||
});
|
||||
if let Some(slot) = moved {
|
||||
self.chiseled_bookshelf_update_state([pos.0, pos.1, pos.2], slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let random = self.particles.level_random();
|
||||
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
|
||||
ambient::client_ai_step(m, &mut events);
|
||||
ambient::client_particles(m, random, &mut events);
|
||||
}
|
||||
for event in events {
|
||||
match event {
|
||||
@@ -37,6 +39,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.eye_particles();
|
||||
self.panda_effects();
|
||||
self.sprint_dust();
|
||||
self.effect_swirls();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
|
||||
//! where the player fell from, turned into the vanilla death message.
|
||||
use super::{Game, chat::PLAYER_NAME};
|
||||
use horizon_client::{health::HealthEvent, motion::MotionEvent, text::Text};
|
||||
use horizon_client::{
|
||||
health::HealthEvent,
|
||||
motion::MotionEvent,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// `EntityEvent.DROWN_PARTICLES`: broadcast with the drowning damage.
|
||||
pub(super) const DROWN_EVENT: i8 = 67;
|
||||
|
||||
struct Entry {
|
||||
damage_type: String,
|
||||
@@ -33,6 +42,10 @@ pub(super) struct CombatTracker {
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
pub(super) refuse_player_hit: bool,
|
||||
/// The player whose melee hit is landing on the player now: where they
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
pub(super) player_attacker: Option<([f64; 3], f32)>,
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
@@ -45,6 +58,38 @@ pub(super) struct CombatTracker {
|
||||
fall_before: f64,
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
|
||||
/// moving with it.
|
||||
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
|
||||
let bubble = ParticleOptions::simple(ParticleType::Bubble);
|
||||
for _ in 0..8 {
|
||||
let mut triangle = || {
|
||||
let r = self.particles.random();
|
||||
r.next_double() - r.next_double()
|
||||
};
|
||||
let p = [at[0] + triangle(), at[1] + triangle(), at[2] + triangle()];
|
||||
self.particles.add(&bubble, p, velocity);
|
||||
}
|
||||
}
|
||||
/// Entity event 67 from a server, for this client's own player or an
|
||||
/// entity it draws.
|
||||
pub(super) fn remote_drown(&mut self, id: i32) {
|
||||
let own = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
|
||||
let body = if own {
|
||||
self.player.as_ref().map(|p| {
|
||||
let (at, v) = (p.position, p.velocity);
|
||||
([at.x, at.y, at.z], [v.x, v.y, v.z])
|
||||
})
|
||||
} else {
|
||||
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
|
||||
let (at, v) = (m.position, m.velocity);
|
||||
([at.x, at.y, at.z], [v.x, v.y, v.z])
|
||||
})
|
||||
};
|
||||
if let Some((at, v)) = body {
|
||||
self.drown_particles(at, v);
|
||||
}
|
||||
}
|
||||
/// The display name of a mob: its custom name or its type's name.
|
||||
pub(super) fn mob_display_name(&self, id: u32) -> Option<Text> {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
@@ -82,8 +127,8 @@ impl Game<'_> {
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
let pitch = self.audio.hurt_pitch();
|
||||
self.audio.play(sound, Some(at), 1.0, pitch);
|
||||
// Player.getVoicePitch: always 1.0.
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// `CombatTracker.recordDamage` for the motion events of this tick.
|
||||
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! tick (`execute`, `executeChain`), `BaseCommandBlock.performCommand`
|
||||
//! through the command system with the block's output, and
|
||||
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
|
||||
use super::{Game, Screen, commands::execute::Source};
|
||||
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
|
||||
use horizon_client::{
|
||||
command_block::{self, CommandBlock, Mode},
|
||||
containers::BlockEntity,
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
use horizon_world::{
|
||||
chunk::BlockState,
|
||||
level::flags,
|
||||
@@ -35,6 +36,16 @@ pub(super) struct CommandBlockScreen {
|
||||
auto: bool,
|
||||
}
|
||||
|
||||
/// What `ServerboundSetCommandBlockPacket` carries.
|
||||
struct CommandBlockEdit {
|
||||
pos: Pos,
|
||||
command: String,
|
||||
track_output: bool,
|
||||
mode: Mode,
|
||||
conditional: bool,
|
||||
auto: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Button {
|
||||
Output,
|
||||
@@ -186,6 +197,18 @@ impl Game<'_> {
|
||||
pub(super) fn can_use_game_master_blocks(&self) -> bool {
|
||||
self.creative && self.ui.registries.permission >= 2
|
||||
}
|
||||
/// `CommandBlock.useWithoutItem` on a server's client, which opens the
|
||||
/// screen itself (`Player.openCommandBlock`) while the server only
|
||||
/// answers the click: the block at `pos`, when it is a command block the
|
||||
/// player may edit.
|
||||
pub(super) fn open_command_block_if_editable(&mut self, pos: Pos) {
|
||||
let is_command_block = self
|
||||
.block_state_at(pos)
|
||||
.is_some_and(|state| Mode::of_block(&state.name).is_some());
|
||||
if is_command_block && self.can_use_game_master_blocks() {
|
||||
self.open_command_block(pos);
|
||||
}
|
||||
}
|
||||
fn command_block(&self, pos: Pos) -> Option<&CommandBlock> {
|
||||
match self.world.block_entities.get(&pos)? {
|
||||
BlockEntity::CommandBlock(c) => Some(c),
|
||||
@@ -572,12 +595,66 @@ impl Game<'_> {
|
||||
s.command.insert_text(&allowed);
|
||||
}
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleSetCommandBlock`.
|
||||
/// The edit screen's Done: a server's client sends the packet, the
|
||||
/// window's own world applies it (`handleSetCommandBlock`).
|
||||
fn set_command_block(&mut self, s: &CommandBlockScreen) {
|
||||
if !self.can_use_game_master_blocks() {
|
||||
self.system_message(Text::translate("advMode.notAllowed", &[]));
|
||||
return;
|
||||
}
|
||||
let edit = CommandBlockEdit {
|
||||
pos: s.pos,
|
||||
command: s.command.value.clone(),
|
||||
track_output: s.track_output,
|
||||
mode: s.mode,
|
||||
conditional: s.conditional,
|
||||
auto: s.auto,
|
||||
};
|
||||
if self.remote.is_some() {
|
||||
self.send(S::SetCommandBlock(play::SetCommandBlock {
|
||||
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
|
||||
command: edit.command,
|
||||
mode: edit.mode.ordinal(),
|
||||
flags: i8::from(edit.track_output)
|
||||
| (i8::from(edit.conditional) << 1)
|
||||
| (i8::from(edit.auto) << 2),
|
||||
}));
|
||||
} else {
|
||||
self.apply_command_block(&edit);
|
||||
}
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleSetCommandBlock` for a guest: the
|
||||
/// block is edited only by a creative player with game master commands.
|
||||
pub(super) fn lan_set_command_block(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: play::SetCommandBlock,
|
||||
) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let allowed = g.creative() && g.permission >= 2;
|
||||
let edit = CommandBlockEdit {
|
||||
pos: (p.pos[0], p.pos[1], p.pos[2]),
|
||||
command: p.command,
|
||||
track_output: p.flags & 1 != 0,
|
||||
mode: Mode::from_ordinal(p.mode),
|
||||
conditional: p.flags & 2 != 0,
|
||||
auto: p.flags & 4 != 0,
|
||||
};
|
||||
self.as_guest(host, conn, |game| {
|
||||
if allowed {
|
||||
game.apply_command_block(&edit);
|
||||
// BaseCommandBlock.onUpdated: the clients get the new command.
|
||||
game.mark_block_entity_updated(edit.pos);
|
||||
} else {
|
||||
game.system_message(Text::translate("advMode.notAllowed", &[]));
|
||||
}
|
||||
});
|
||||
}
|
||||
/// What `handleSetCommandBlock` does once the player may edit the block.
|
||||
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
|
||||
let pos = s.pos;
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(current) = self.block_state_at(pos) else {
|
||||
@@ -609,7 +686,7 @@ impl Game<'_> {
|
||||
}
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
let command = s.command.value.clone();
|
||||
let command = s.command.clone();
|
||||
let Some(c) = command_block_mut(&mut self.world, pos) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1041,6 +1041,17 @@ impl Game<'_> {
|
||||
// An air stack is empty: nothing is given (but the message still is).
|
||||
let empty = id == "minecraft:air";
|
||||
for &t in targets.iter().filter(|_| !empty) {
|
||||
// The player's UUID, which an item they could not hold is set to.
|
||||
let conn = match t {
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => self.lan_acting,
|
||||
};
|
||||
let uuid = match conn {
|
||||
Some(conn) => self
|
||||
.command_guest(conn)
|
||||
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid)),
|
||||
None => Some(self.level.ensure_player_uuid()),
|
||||
};
|
||||
self.for_player(t, |g| {
|
||||
let mut remaining = count;
|
||||
while remaining > 0 {
|
||||
@@ -1057,8 +1068,19 @@ impl Game<'_> {
|
||||
.target_position(Target::Player)
|
||||
.unwrap_or(g.source_position());
|
||||
if let Some(rest) = left {
|
||||
// Inventory full: drop the rest at the player's feet without pickup delay.
|
||||
g.spawn_item(rest, [p[0], p[1] + 1.32, p[2]], [0.0, 0.0, 0.0], 0);
|
||||
// Inventory full: Player.createItemStackToDrop (thrown from
|
||||
// the eyes), then setNoPickUpDelay and setTarget(player).
|
||||
let eye = g.eye_position().unwrap_or([
|
||||
p[0],
|
||||
p[1] + g.target_eye_height(Target::Player),
|
||||
p[2],
|
||||
]);
|
||||
g.throw_from_eyes(rest, eye, false);
|
||||
if let Some(item) = g.dropped.last_mut() {
|
||||
item.thrower = None;
|
||||
item.pickup_delay = 0;
|
||||
item.target = uuid;
|
||||
}
|
||||
} else {
|
||||
let r = &mut g.world.random;
|
||||
let pitch = ((r.next_float() - r.next_float()) * 0.7 + 1.0) * 2.0;
|
||||
@@ -1311,7 +1333,7 @@ impl Game<'_> {
|
||||
{
|
||||
return Err(err("commands.teleport.invalidPosition", &[]));
|
||||
}
|
||||
let mut rotation = match ctx.get("rotation") {
|
||||
let rotation = match ctx.get("rotation") {
|
||||
Some(Arg::Coords(c)) => Some(c.rotation(self.source_rotation())),
|
||||
_ => None,
|
||||
};
|
||||
@@ -1326,21 +1348,31 @@ impl Game<'_> {
|
||||
(Some(f), _) => Some(f),
|
||||
(None, Some(_)) => {
|
||||
let e = self.entities(ctx, "facingEntity", false)?[0];
|
||||
// EntityAnchorArgument.Anchor.apply: the feet, or the eyes.
|
||||
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
|
||||
let eye_height = if eyes { self.target_eye_height(e) } else { 0.0 };
|
||||
self.target_position(e)
|
||||
.map(|p| [p[0], p[1] + if eyes { 1.62 } else { 0.0 }, p[2]])
|
||||
.map(|p| [p[0], p[1] + eye_height, p[2]])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = facing {
|
||||
let eye = pos[1] + 1.62;
|
||||
// Entity.lookAt: from the source's anchor (the feet, unless `anchored
|
||||
// eyes`) on the teleported entity, so the eyes depend on the target.
|
||||
let anchored_eyes = self.command_source.is_some_and(|s| s.eyes);
|
||||
let look_at = |this: &Self, t: Target| {
|
||||
let f = facing?;
|
||||
let eye = if anchored_eyes {
|
||||
pos[1] + this.target_eye_height(t)
|
||||
} else {
|
||||
pos[1]
|
||||
};
|
||||
let (dx, dy, dz) = (f[0] - pos[0], f[1] - eye, f[2] - pos[2]);
|
||||
let horizontal = (dx * dx + dz * dz).sqrt();
|
||||
rotation = Some([
|
||||
Some([
|
||||
((dz.atan2(dx).to_degrees()) - 90.0) as f32,
|
||||
(-(dy.atan2(horizontal).to_degrees())) as f32,
|
||||
]);
|
||||
}
|
||||
])
|
||||
};
|
||||
// A server's player in another level than the source's (`execute
|
||||
// in`, the console's Overworld) moves there (ServerPlayer.teleport
|
||||
// into another level), after the command.
|
||||
@@ -1350,6 +1382,7 @@ impl Game<'_> {
|
||||
None => self.dim,
|
||||
};
|
||||
for &t in &targets {
|
||||
let rotation = look_at(self, t).or(rotation);
|
||||
if self.teleport_across(t, source_level, pos, rotation) {
|
||||
continue;
|
||||
}
|
||||
@@ -2057,7 +2090,8 @@ impl Game<'_> {
|
||||
let dmax = [dest[0] + size[0], dest[1] + size[1], dest[2] + size[2]];
|
||||
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
|
||||
let moving = ctx.has("move");
|
||||
if !ctx.has("force") && overlap {
|
||||
// Mode.canOverlap: only the normal mode refuses overlapping regions.
|
||||
if !ctx.has("force") && !moving && overlap {
|
||||
return Err(err("commands.clone.overlap", &[]));
|
||||
}
|
||||
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
|
||||
@@ -2266,26 +2300,54 @@ impl Game<'_> {
|
||||
let levels = ctx.has("levels");
|
||||
let unit = if levels { "levels" } else { "points" };
|
||||
if ctx.has("query") {
|
||||
let _ = self.entities(ctx, "target", true)?;
|
||||
let e = &self.experience;
|
||||
let value = if levels {
|
||||
e.level
|
||||
} else {
|
||||
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32) as i32
|
||||
};
|
||||
let target = self.entities(ctx, "target", true)?[0];
|
||||
// ExperienceCommand.queryExperience: the target's own level or points.
|
||||
let value = self
|
||||
.for_player(target, |g| {
|
||||
let e = &g.experience;
|
||||
if levels {
|
||||
e.level
|
||||
} else {
|
||||
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32)
|
||||
as i32
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
self.system_message(Text::translate(
|
||||
&format!("commands.experience.query.{unit}"),
|
||||
&[
|
||||
Text::literal(self.acting_player_name()),
|
||||
Text::literal(value.to_string()),
|
||||
],
|
||||
&[self.target_name(target), Text::literal(value.to_string())],
|
||||
));
|
||||
return Ok(value);
|
||||
}
|
||||
let targets = self.entities(ctx, "target", true)?;
|
||||
let amount = Self::int(ctx, "amount").unwrap_or(0);
|
||||
let op = if ctx.has("add") { "add" } else { "set" };
|
||||
if op == "add" {
|
||||
let add = ctx.has("add");
|
||||
let op = if add { "add" } else { "set" };
|
||||
// Every target changes (a set of points can be refused for some).
|
||||
let changed: Vec<Target> = targets
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&t| {
|
||||
self.for_player(t, |g| g.change_experience(add, levels, amount)) == Some(true)
|
||||
})
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return Err(err("commands.experience.set.points.invalid", &[]));
|
||||
}
|
||||
let (key, who) = match changed[..] {
|
||||
[only] => ("single", self.target_name(only)),
|
||||
_ => ("multiple", Text::literal(changed.len().to_string())),
|
||||
};
|
||||
self.feedback(Text::translate(
|
||||
&format!("commands.experience.{op}.{unit}.success.{key}"),
|
||||
&[Text::literal(amount.to_string()), who],
|
||||
));
|
||||
Ok(changed.len() as i32)
|
||||
}
|
||||
/// ExperienceCommand's add and set on the player in place: false when
|
||||
/// the points to set do not fit the level.
|
||||
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
|
||||
if add {
|
||||
if levels {
|
||||
self.experience.level = (self.experience.level + amount).max(0);
|
||||
if self.experience.level == 0 && amount < 0 {
|
||||
@@ -2299,26 +2361,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
let needed = horizon_client::entities::Experience::needed(self.experience.level);
|
||||
if amount > needed - 1 {
|
||||
return Err(err("commands.experience.set.points.invalid", &[]));
|
||||
return false;
|
||||
}
|
||||
self.experience.progress = amount as f32 / needed as f32;
|
||||
}
|
||||
let key = format!(
|
||||
"commands.experience.{op}.{unit}.success.{}",
|
||||
if targets.len() == 1 {
|
||||
"single"
|
||||
} else {
|
||||
"multiple"
|
||||
}
|
||||
);
|
||||
self.feedback(Text::translate(
|
||||
&key,
|
||||
&[
|
||||
Text::literal(amount.to_string()),
|
||||
Text::literal(self.acting_player_name()),
|
||||
],
|
||||
));
|
||||
Ok(targets.len() as i32)
|
||||
true
|
||||
}
|
||||
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
|
||||
let Some(name) = ctx.literals.get(1).cloned() else {
|
||||
@@ -2403,21 +2450,26 @@ impl Game<'_> {
|
||||
};
|
||||
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
let pitch = rot[1].clamp(-90.0, 90.0);
|
||||
// SetSpawnCommand: a forced point in the source's level.
|
||||
if self.dim == Dim::Overworld {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
} else {
|
||||
self.spawn = None;
|
||||
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
// SetSpawnCommand: a forced point in the source's level, for each target.
|
||||
let level = self.dim;
|
||||
for &t in &targets {
|
||||
self.for_player(t, |g| {
|
||||
if level == Dim::Overworld {
|
||||
g.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
g.respawn_config = None;
|
||||
} else {
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
@@ -2597,6 +2649,44 @@ impl Game<'_> {
|
||||
},
|
||||
};
|
||||
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
|
||||
let counting = max == 0;
|
||||
// ClearInventoryCommands.clearInventory: each player on their own.
|
||||
let mut removed = 0i32;
|
||||
let mut found = Vec::new();
|
||||
for &t in &targets {
|
||||
let n = self
|
||||
.for_player(t, |g| g.clear_matching(&predicate, max))
|
||||
.unwrap_or(0);
|
||||
removed += n;
|
||||
if n != 0 {
|
||||
found.push(t);
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
return Err(if targets.len() == 1 {
|
||||
CommandError::new(Text::translate(
|
||||
"clear.failed.single",
|
||||
&[self.target_name(targets[0])],
|
||||
))
|
||||
} else {
|
||||
err("clear.failed.multiple", &[&targets.len()])
|
||||
});
|
||||
}
|
||||
let kind = if counting { "test" } else { "success" };
|
||||
let (key, who) = match found[..] {
|
||||
[only] => ("single", self.target_name(only)),
|
||||
_ => ("multiple", Text::literal(found.len().to_string())),
|
||||
};
|
||||
self.feedback(Text::translate(
|
||||
&format!("commands.clear.{kind}.{key}"),
|
||||
&[Text::literal(removed.to_string()), who],
|
||||
));
|
||||
Ok(removed)
|
||||
}
|
||||
/// Inventory.clearOrCountMatchingItems for the player in place: `max`
|
||||
/// of the matching items go (all when negative, none and only counted
|
||||
/// when 0). Returns how many matched.
|
||||
fn clear_matching(&mut self, predicate: &ItemInput, max: i32) -> i32 {
|
||||
let counting = max == 0;
|
||||
// Inventory slots 0..40: main, armor feet to head, off hand;
|
||||
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
|
||||
@@ -2612,10 +2702,7 @@ impl Game<'_> {
|
||||
};
|
||||
let matching: Vec<bool> = all(self)
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.as_ref()
|
||||
.is_some_and(|s| self.stack_matches(&predicate, s))
|
||||
})
|
||||
.map(|s| s.as_ref().is_some_and(|s| self.stack_matches(predicate, s)))
|
||||
.collect();
|
||||
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
|
||||
slots.extend(self.inventory.iter_mut());
|
||||
@@ -2645,25 +2732,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
return Err(if targets.len() == 1 {
|
||||
CommandError::new(Text::translate(
|
||||
"clear.failed.single",
|
||||
&[Text::literal(self.acting_player_name())],
|
||||
))
|
||||
} else {
|
||||
err("clear.failed.multiple", &[&targets.len()])
|
||||
});
|
||||
}
|
||||
let kind = if counting { "test" } else { "success" };
|
||||
self.feedback(Text::translate(
|
||||
&format!("commands.clear.{kind}.single"),
|
||||
&[
|
||||
Text::literal(removed.to_string()),
|
||||
Text::literal(self.acting_player_name()),
|
||||
],
|
||||
));
|
||||
Ok(removed)
|
||||
removed
|
||||
}
|
||||
/// `PoiTypes`: the point of interest type of a block state.
|
||||
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! `/effect` (EffectCommands) and `/enchant` (EnchantCommand).
|
||||
use super::{Arg, Context, Game, R, Target, err, texts};
|
||||
use horizon_client::{
|
||||
brewing,
|
||||
brewing::{self, namespaced},
|
||||
effects::EffectInstance,
|
||||
text::{self, Style, Text},
|
||||
};
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
|
||||
/// `CommandResponseTracker` over targets: elements with a non-zero result.
|
||||
#[derive(Default)]
|
||||
@@ -21,24 +20,6 @@ impl Tracker {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
IDS.get_or_init(|| {
|
||||
brewing::EFFECTS
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.id,
|
||||
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
fn effect_name(id: &str) -> Text {
|
||||
Text::translate(&format!("effect.minecraft.{id}"), &[])
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ impl Game<'_> {
|
||||
match ty.trim_start_matches("minecraft:") {
|
||||
"player_attack" => DamageKind::PlayerAttack,
|
||||
"mace_smash" => DamageKind::MaceSmash,
|
||||
"spear" => DamageKind::Spear,
|
||||
"mob_attack" | "mob_attack_no_aggro" => DamageKind::MobAttack,
|
||||
"arrow" => DamageKind::Arrow,
|
||||
"thrown" | "trident" | "mob_projectile" => DamageKind::Thrown,
|
||||
@@ -118,7 +119,9 @@ impl Game<'_> {
|
||||
"dry_out" => DamageKind::DryOut,
|
||||
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
|
||||
"sting" => DamageKind::Sting,
|
||||
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
|
||||
"cactus" => DamageKind::Cactus,
|
||||
"sweet_berry_bush" => DamageKind::SweetBerryBush,
|
||||
"freeze" => DamageKind::Freeze,
|
||||
"in_wall" => DamageKind::InWall,
|
||||
"thorns" => DamageKind::Thorns,
|
||||
_ => DamageKind::Generic,
|
||||
@@ -1218,7 +1221,7 @@ impl Game<'_> {
|
||||
/// LivingEntity.stopRiding for a mob: off its vehicle
|
||||
/// (Entity.removePassenger), then to where the vehicle lets it out
|
||||
/// (LivingEntity.dismountVehicle).
|
||||
fn dismount_mob(&mut self, rider: u32) {
|
||||
pub(in crate::game) fn dismount_mob(&mut self, rider: u32) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
|
||||
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
|
||||
use super::Game;
|
||||
use horizon_client::{hotbar, keybinds, text::Text};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the key bound to `mapping` is held.
|
||||
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
|
||||
keybinds::key_code(self.settings.keys.get(mapping))
|
||||
.is_some_and(|k| self.physical_keys.contains(&k))
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
|
||||
/// `index`: load puts the saved row in the hotbar (the creative slot
|
||||
/// packets follow from the inventory change), save stores the hotbar and
|
||||
/// says so on the action bar.
|
||||
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
|
||||
let path = Self::options_path().with_file_name("hotbar.nbt");
|
||||
let known = |id: &str| self.res.items().get(id).is_some();
|
||||
let mut rows = hotbar::load(&path, &known);
|
||||
if load {
|
||||
for (slot, stack) in rows[index].iter().enumerate() {
|
||||
self.inventory[slot] = stack.clone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
|
||||
let _ = hotbar::save(&path, &rows);
|
||||
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
|
||||
let message = Text::translate(
|
||||
"inventory.hotbarSaved",
|
||||
&[
|
||||
key("key.loadToolbarActivator"),
|
||||
key(&format!("key.hotbar.{}", index + 1)),
|
||||
],
|
||||
);
|
||||
self.titles.set_overlay(message, false);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
save::VERSION_NAME,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Axis;
|
||||
use horizon_core::geometry::{Axis, Vec3};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `ChatFormatting.YELLOW` and `RED`.
|
||||
@@ -244,7 +244,8 @@ impl Game<'_> {
|
||||
if on { "visible" } else { "hidden" }
|
||||
)
|
||||
};
|
||||
let remote = self.remote.is_some();
|
||||
// `DebugScreenOverlay`: the single-player variants for a local server.
|
||||
let remote = !self.has_singleplayer_server();
|
||||
let c = &self.debug_charts;
|
||||
[
|
||||
format!(
|
||||
@@ -463,7 +464,7 @@ impl Game<'_> {
|
||||
height,
|
||||
self.settings.max_fps,
|
||||
1000.0 / self.clock.rate(),
|
||||
self.remote.is_some(),
|
||||
!self.has_singleplayer_server(),
|
||||
);
|
||||
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
|
||||
scale, scale,
|
||||
@@ -766,7 +767,8 @@ impl Game<'_> {
|
||||
}
|
||||
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
||||
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
|
||||
for m in &self.mobs {
|
||||
let guests = self.lan.iter().flat_map(|h| h.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
|
||||
continue;
|
||||
}
|
||||
@@ -796,6 +798,159 @@ impl Game<'_> {
|
||||
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
|
||||
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
|
||||
}
|
||||
// The other entities (EntityTypes sizes; eyes at 0.85 of the height
|
||||
// unless the type sets one). `at` is the interpolated position.
|
||||
let at = |previous: Vec3, now: Vec3| {
|
||||
[
|
||||
lerp(previous.x, now.x),
|
||||
lerp(previous.y, now.y),
|
||||
lerp(previous.z, now.z),
|
||||
]
|
||||
};
|
||||
for p in &self.paintings {
|
||||
let (min, max) = p.bounds();
|
||||
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
|
||||
g.entity_box(min, max, centre, 0.425, false, step_view(p.step()));
|
||||
}
|
||||
for f in self.item_frames.iter().filter(|f| !f.invisible) {
|
||||
let (min, max) = f.bounds();
|
||||
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
|
||||
g.entity_box(min, max, centre, 0.0, false, step_view(f.step()));
|
||||
}
|
||||
for c in &self.cushions {
|
||||
let (min, max) = c.bounds();
|
||||
let p = c.position;
|
||||
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
|
||||
}
|
||||
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
|
||||
// ArmorStand.getDimensions: a marker has none, a small one is
|
||||
// half scale with its eyes at 0.9875.
|
||||
let (size, eye) = match (a.marker, a.small) {
|
||||
(true, _) => ((0.0, 0.0), 0.0),
|
||||
(_, true) => ((0.25, 0.9875), 0.9875),
|
||||
_ => ((0.5, 1.975), 1.7775),
|
||||
};
|
||||
let p = a.position;
|
||||
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
|
||||
}
|
||||
for b in &self.boats {
|
||||
let h = super::boats::HEIGHT as f32;
|
||||
g.hitbox(
|
||||
at(b.previous, b.position),
|
||||
(super::boats::WIDTH as f32, h),
|
||||
h,
|
||||
false,
|
||||
(0.0, b.yaw),
|
||||
);
|
||||
}
|
||||
for c in &self.carts {
|
||||
let (w, h) = (
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
g.hitbox(
|
||||
at(c.previous, c.position),
|
||||
(w, h),
|
||||
h * 0.85,
|
||||
false,
|
||||
(0.0, c.yaw),
|
||||
);
|
||||
}
|
||||
for o in &self.orbs {
|
||||
g.hitbox(
|
||||
at(o.previous, o.position),
|
||||
(0.5, 0.5),
|
||||
0.425,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for f in &self.falling {
|
||||
g.hitbox(
|
||||
at(f.previous, f.position),
|
||||
(0.98, 0.98),
|
||||
0.833,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for t in &self.tnts {
|
||||
g.hitbox(
|
||||
at(t.previous, t.position),
|
||||
(0.98, 0.98),
|
||||
0.15,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for c in &self.crystals {
|
||||
let p = c.position;
|
||||
g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0));
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let (size, eye) = match p.kind {
|
||||
K::Arrow { .. } | K::Trident => (0.5, 0.13),
|
||||
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
|
||||
(0.25, 0.2125)
|
||||
}
|
||||
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
|
||||
(0.3125, 0.265_625)
|
||||
}
|
||||
K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0),
|
||||
K::LargeFireball { .. } => (1.0, 0.85),
|
||||
};
|
||||
g.hitbox(
|
||||
at(p.previous, p.position),
|
||||
(size, size),
|
||||
eye,
|
||||
false,
|
||||
(p.pitch, p.yaw),
|
||||
);
|
||||
}
|
||||
for t in &self.tridents {
|
||||
g.hitbox(
|
||||
at(t.previous, t.position),
|
||||
(0.5, 0.5),
|
||||
0.13,
|
||||
false,
|
||||
(t.pitch, t.yaw),
|
||||
);
|
||||
}
|
||||
for r in &self.rockets {
|
||||
// Projectile.updateRotation: the look follows the motion.
|
||||
let v = r.velocity;
|
||||
let yaw = v.x.atan2(v.z).to_degrees() as f32;
|
||||
let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32;
|
||||
g.hitbox(
|
||||
at(r.previous, r.position),
|
||||
(0.25, 0.25),
|
||||
0.2125,
|
||||
false,
|
||||
(pitch, yaw),
|
||||
);
|
||||
}
|
||||
for h in self
|
||||
.hook
|
||||
.iter()
|
||||
.map(|h| (h.previous, h.position))
|
||||
.chain(self.remote_hooks.iter().map(|h| (h.previous, h.position)))
|
||||
{
|
||||
g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0));
|
||||
}
|
||||
for b in &self.bolts {
|
||||
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
|
||||
}
|
||||
// The dragon: 16 by 8, a living entity; its parts follow.
|
||||
if let Some(d) = &self.dragon {
|
||||
g.hitbox(
|
||||
at(d.previous, d.position),
|
||||
(16.0, 8.0),
|
||||
6.8,
|
||||
true,
|
||||
(0.0, d.yaw),
|
||||
);
|
||||
}
|
||||
// The dragon's parts.
|
||||
for (id, b) in self.special_targets() {
|
||||
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
|
||||
@@ -828,3 +983,12 @@ impl Game<'_> {
|
||||
g
|
||||
}
|
||||
}
|
||||
|
||||
/// The (x, y) rotations whose view vector is the step of a direction.
|
||||
fn step_view(step: [i32; 3]) -> (f32, f32) {
|
||||
let [x, y, z] = step.map(f64::from);
|
||||
(
|
||||
(-y).asin().to_degrees() as f32,
|
||||
(-x.atan2(z)).to_degrees() as f32,
|
||||
)
|
||||
}
|
||||
@@ -409,6 +409,8 @@ struct ServerState {
|
||||
paused: bool,
|
||||
/// `broadcast-console-to-ops`.
|
||||
broadcast_console: bool,
|
||||
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
|
||||
op_level: i32,
|
||||
}
|
||||
|
||||
/// The dedicated-only commands' replies vanilla sends with `broadcast`
|
||||
@@ -570,6 +572,14 @@ impl Game<'_> {
|
||||
// DedicatedServer.initServer: worldData.setGameType; forceDifficulty.
|
||||
self.set_default_game_mode(game_mode);
|
||||
self.ui.difficulty = props.difficulty as u8;
|
||||
// DedicatedServer.initServer: a legacy `announce-player-achievements`
|
||||
// sets the show_advancement_messages rule once, as the file is read.
|
||||
if let Some(announce) = props.announce_player_achievements {
|
||||
self.ui.rules.set(
|
||||
"show_advancement_messages",
|
||||
horizon_client::gamerules::Value::Bool(announce),
|
||||
);
|
||||
}
|
||||
self.settings.render_distance = props.view_distance.clamp(2, 32);
|
||||
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
@@ -644,6 +654,9 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.spawn_protection = props.spawn_protection;
|
||||
}
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
@@ -666,6 +679,7 @@ impl Game<'_> {
|
||||
ticks: Default::default(),
|
||||
paused: false,
|
||||
broadcast_console: props.broadcast_console_to_ops,
|
||||
op_level: props.op_permission_level,
|
||||
};
|
||||
let pause_after = props.pause_when_empty_seconds * 20;
|
||||
let idle_timeout = props.player_idle_timeout;
|
||||
@@ -927,7 +941,7 @@ impl Game<'_> {
|
||||
let Some(user) = self.resolve(rest[0]) else {
|
||||
return;
|
||||
};
|
||||
let level = 4;
|
||||
let level = state.op_level;
|
||||
let changed = {
|
||||
let mut l = state.lists.lock().unwrap();
|
||||
if command == "op" {
|
||||
@@ -953,6 +967,10 @@ impl Game<'_> {
|
||||
self.console_reply(Text::translate(key, &[lit(&user.name)]));
|
||||
let lists = state.lists.clone();
|
||||
self.sync_ops(&lists);
|
||||
// DeOpCommands.deopPlayers.
|
||||
if command == "deop" && changed {
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
}
|
||||
"whitelist" => self.console_whitelist(state, &rest),
|
||||
"ban" if !rest.is_empty() => {
|
||||
@@ -1164,6 +1182,7 @@ impl Game<'_> {
|
||||
["on"] | ["off"] => {
|
||||
let on = rest[0] == "on";
|
||||
let mut l = state.lists.lock().unwrap();
|
||||
let turned_on = on && !l.use_whitelist;
|
||||
let key = match (on, l.use_whitelist) {
|
||||
(true, true) => "commands.whitelist.alreadyOn",
|
||||
(true, false) => "commands.whitelist.enabled",
|
||||
@@ -1174,6 +1193,10 @@ impl Game<'_> {
|
||||
drop(l);
|
||||
self.store_property("white-list", on.to_string());
|
||||
self.console_reply(Text::translate(key, &[]));
|
||||
if turned_on {
|
||||
let lists = state.lists.clone();
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
}
|
||||
["list"] => {
|
||||
let names: Vec<String> = state
|
||||
@@ -1197,6 +1220,8 @@ impl Game<'_> {
|
||||
["reload"] => {
|
||||
let _ = state.lists.lock().unwrap().white.load();
|
||||
self.console_reply(Text::translate("commands.whitelist.reloaded", &[]));
|
||||
let lists = state.lists.clone();
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
["add", name] | ["remove", name] => {
|
||||
let Some(user) = self.resolve(name) else {
|
||||
@@ -1222,6 +1247,10 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
self.console_reply(Text::translate(key, &args));
|
||||
if ok && rest[0] == "remove" {
|
||||
let lists = state.lists.clone();
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
}
|
||||
_ => self.console_reply(Text::translate("command.unknown.argument", &[])),
|
||||
}
|
||||
@@ -1237,6 +1266,38 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// MinecraftServer.kickUnlistedPlayers: with `enforce-whitelist` and the
|
||||
/// whitelist on, whoever is online and not on the whitelist file is
|
||||
/// disconnected (operators are not spared here).
|
||||
fn kick_unlisted_players(&mut self, lists: &Arc<Mutex<Lists>>) {
|
||||
let unlisted: Vec<u128> = {
|
||||
let l = lists.lock().unwrap();
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if !(l.enforce_whitelist && l.use_whitelist) {
|
||||
return;
|
||||
}
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
!l.white.contains(&NameAndId {
|
||||
id: g.uuid,
|
||||
name: g.name.clone(),
|
||||
})
|
||||
})
|
||||
.map(|g| g.uuid)
|
||||
.collect()
|
||||
};
|
||||
for uuid in unlisted {
|
||||
self.kick_uuid(
|
||||
uuid,
|
||||
translatable("multiplayer.disconnect.not_whitelisted", &[]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
let conns: Vec<u32> = h
|
||||
|
||||
@@ -672,6 +672,7 @@ impl Game<'_> {
|
||||
}
|
||||
let source = match source.kind {
|
||||
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
|
||||
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
|
||||
_ => "minecraft:player_attack",
|
||||
};
|
||||
out.push(format!(
|
||||
|
||||
@@ -1548,6 +1548,7 @@ impl Game<'_> {
|
||||
return Ok((Some(stack), FAIL));
|
||||
};
|
||||
let target = (placed.x, placed.y, placed.z);
|
||||
let state = self.apply_item_block_state([target.0, target.1, target.2], state, &stack)?;
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
|
||||
self.world.block_entities.insert(target, entity);
|
||||
self.apply_placed_item([target.0, target.1, target.2], &stack, false);
|
||||
|
||||
@@ -48,6 +48,9 @@ pub(super) struct Fight {
|
||||
respawn: Option<(Respawn, i32)>,
|
||||
/// The four crystals on the exit portal driving the respawn.
|
||||
respawn_crystals: Vec<Vec3>,
|
||||
/// The player behind the crystal blast under way, whose `explosion`
|
||||
/// damage source names them (`EndCrystal.hurtServer`).
|
||||
blast_by: Option<FightWho>,
|
||||
dirty: bool,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -262,6 +265,9 @@ pub(super) struct Dragon {
|
||||
/// only damage above the last hit lands, and only the excess.
|
||||
invulnerable: i32,
|
||||
last_hurt: f32,
|
||||
/// `lastHurtByPlayer` with its 100 tick memory: the player credited with
|
||||
/// the kill, and the dragon tick of their last hit.
|
||||
hurt_by_player: Option<(FightWho, i32)>,
|
||||
pub death_time: i32,
|
||||
pub flap: f32,
|
||||
pub old_flap: f32,
|
||||
@@ -385,6 +391,7 @@ impl Dragon {
|
||||
hurt_time: 0,
|
||||
invulnerable: 0,
|
||||
last_hurt: 0.0,
|
||||
hurt_by_player: None,
|
||||
death_time: 0,
|
||||
flap: 0.0,
|
||||
old_flap: 0.0,
|
||||
@@ -426,6 +433,10 @@ impl Dragon {
|
||||
pub fn alive(&self) -> bool {
|
||||
self.health > 0.0
|
||||
}
|
||||
/// `isDeadOrDying` for the killing blow: the dying flight, or no health.
|
||||
fn down(&self) -> bool {
|
||||
matches!(self.phase, Phase::Dying { .. }) || !self.alive()
|
||||
}
|
||||
fn part_box(&self, i: usize) -> Aabb {
|
||||
let (w, h) = PART_SIZES[i];
|
||||
let p = self.parts[i].0;
|
||||
@@ -891,8 +902,12 @@ impl Game<'_> {
|
||||
.any(|p| p.distance_squared(c.position) < 0.01);
|
||||
if damage == CrystalDamage::Direct {
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
// The blast's damage source carries the player, so the dragon
|
||||
// remembers them before the blast can kill it.
|
||||
self.fight.blast_by = by;
|
||||
let _ =
|
||||
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
|
||||
self.fight.blast_by = None;
|
||||
}
|
||||
if respawn_crystal {
|
||||
self.abort_respawn();
|
||||
@@ -903,7 +918,7 @@ impl Game<'_> {
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
if d.nearest_crystal == Some(index) {
|
||||
d.nearest_crystal = None;
|
||||
self.hurt_dragon(0, 10.0, true, false);
|
||||
self.hurt_dragon(0, 10.0, true, by, false);
|
||||
} else if let Some(n) = d.nearest_crystal.as_mut()
|
||||
&& *n > index
|
||||
{
|
||||
@@ -975,17 +990,43 @@ impl Game<'_> {
|
||||
self.destroy_crystal(index, by, CrystalDamage::Direct);
|
||||
Hurt::Landed { full: true }
|
||||
} else if id >= SPECIAL {
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
|
||||
let by = by_player.then(|| self.acting_player());
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow)
|
||||
} else {
|
||||
Hurt::Refused
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.hurt(part, source, damage)` on the dragon in the level.
|
||||
fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt {
|
||||
/// `by` is the player behind the hit, remembered for the kill credit.
|
||||
fn hurt_dragon(
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: bool,
|
||||
by: Option<FightWho>,
|
||||
arrow: bool,
|
||||
) -> Hurt {
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return Hurt::Refused;
|
||||
};
|
||||
let was_down = d.down();
|
||||
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
|
||||
let hurt = d.hurt(part, damage, counts, arrow);
|
||||
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
|
||||
// (100 ticks); a hit the damage cooldown absorbs returns before that.
|
||||
if counts
|
||||
&& matches!(hurt, Hurt::Landed { .. })
|
||||
&& (d.invulnerable != cooldown || d.last_hurt != last_hurt)
|
||||
&& let Some(who) = by
|
||||
{
|
||||
d.hurt_by_player = Some((who, d.ticks));
|
||||
}
|
||||
if !was_down && d.down() {
|
||||
self.credit_dragon_kill();
|
||||
}
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
return hurt;
|
||||
};
|
||||
if hurt == (Hurt::Landed { full: true }) {
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
@@ -994,6 +1035,47 @@ impl Game<'_> {
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `LivingEntity.die`: `getKillCredit().awardKillScore`, for the player
|
||||
/// who hurt the dragon within the last 100 ticks. A guest's kill is
|
||||
/// theirs: their statistics, scores and advancements.
|
||||
fn credit_dragon_kill(&mut self) {
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some((who, _)) = d.hurt_by_player.filter(|(_, hit)| d.ticks - hit <= 100) else {
|
||||
return;
|
||||
};
|
||||
let view = horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:ender_dragon".to_owned(),
|
||||
position: [d.position.x, d.position.y, d.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
let id = d.id;
|
||||
let guest = match who {
|
||||
FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
let credited = guest.is_some_and(|conn| {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
let done = self
|
||||
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view))
|
||||
.is_some();
|
||||
self.lan = Some(host);
|
||||
done
|
||||
});
|
||||
if !credited {
|
||||
self.award_dragon_kill(id, &view);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.awardKillScore` for the dragon, on the killer's state.
|
||||
fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) {
|
||||
self.stats.add("killed", "minecraft:ender_dragon", 1);
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(id));
|
||||
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
|
||||
}
|
||||
/// Player melee on the dragon's parts or a crystal under the crosshair.
|
||||
/// The dragon part under the crosshair within reach: its id and distance.
|
||||
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
|
||||
@@ -1031,14 +1113,11 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
let modifiers = self
|
||||
.attack_item_info()
|
||||
.map(|i| (i.attack_damage, i.attack_speed))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let speed = (4.0 + modifiers.1).max(0.1);
|
||||
let scale = ((self.attack_ticks as f32 + 0.5) / (20.0 / speed)).clamp(0.0, 1.0);
|
||||
let hand_damage = self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
|
||||
let scale = ((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0);
|
||||
let mut amount =
|
||||
(1.0 + modifiers.0 + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
|
||||
(1.0 + hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
|
||||
let falling = self
|
||||
.player
|
||||
.as_ref()
|
||||
@@ -1374,7 +1453,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let d2 = (pp.x - pos.x).powi(2) + (pp.y - pos.y).powi(2) + (pp.z - pos.z).powi(2);
|
||||
if d2 < 4096.0 {
|
||||
// Entity.hasLineOfSight: eye to eye (the dragon's eye is 0.85 of
|
||||
// its 8 block height), blocks only.
|
||||
let sees = d2 < 4096.0 && {
|
||||
let eye = Vec3::new(pos.x, pos.y + 6.8, pos.z);
|
||||
let target_eye =
|
||||
tracked.map_or(pp.y + 1.62, |p| p.bounds.max_position().y - 0.18);
|
||||
self.mob_world_base(&[])
|
||||
.line_of_sight(eye, Vec3::new(pp.x, target_eye, pp.z))
|
||||
};
|
||||
if sees {
|
||||
*charge += 1;
|
||||
let d = self.dragon.as_ref().expect("dragon");
|
||||
let r = d.yaw.to_radians();
|
||||
@@ -2127,10 +2215,12 @@ impl Game<'_> {
|
||||
c.at[1],
|
||||
c.at[2] + f64::from(a.sin() * d),
|
||||
];
|
||||
self.particles.add(
|
||||
self.particles.add_with(
|
||||
&ParticleOptions::simple(ParticleType::DragonBreath),
|
||||
at,
|
||||
[0.0, 0.0, 0.0],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
|
||||
@@ -2720,8 +2810,9 @@ impl Game<'_> {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
});
|
||||
let by = self.fight.blast_by;
|
||||
for (part, amount) in hits {
|
||||
self.hurt_dragon(part, amount, true, false);
|
||||
self.hurt_dragon(part, amount, true, by, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2859,14 +2950,11 @@ impl Game<'_> {
|
||||
let (a, b) = (room.min_position(), room.max_position());
|
||||
lo.x < b.x && hi.x > a.x && lo.y < b.y && hi.y > a.y && lo.z < b.z && hi.z > a.z
|
||||
};
|
||||
let player = self.player.as_ref().is_some_and(|p| {
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let h = f64::from(p.dimensions.height);
|
||||
touches(
|
||||
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
|
||||
Vec3::new(p.position.x + w, p.position.y + h, p.position.z + w),
|
||||
)
|
||||
});
|
||||
// Level.getEntities: the host's player and every guest in the level.
|
||||
let player = self
|
||||
.fight_players()
|
||||
.iter()
|
||||
.any(|p| !p.spectator && touches(p.bounds.min_position(), p.bounds.max_position()));
|
||||
player
|
||||
|| self.crystals.iter().any(|c| {
|
||||
touches(
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
//! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power
|
||||
//! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`),
|
||||
//! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and
|
||||
//! the armor attribute effects (respiration, depth strider, ...) and Soul
|
||||
//! Speed's `location_changed` and `tick` effects.
|
||||
//! the armor attribute effects (respiration, depth strider, ...), Soul
|
||||
//! Speed's `location_changed` and `tick` effects and the
|
||||
//! `minecraft:location_changed` disk effect (frost walker).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
enchant::{EffectContext, Enchantments, level_value},
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -69,7 +73,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `getEnchantedDamage - base`: damage bonus of the held weapon against a mob type.
|
||||
pub(super) fn enchant_damage_bonus(&mut self, target: &str) -> f32 {
|
||||
let Some(held) = self.inventory[self.selected].clone() else {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return 0.0;
|
||||
};
|
||||
let ctx = EffectContext {
|
||||
@@ -102,7 +106,7 @@ impl Game<'_> {
|
||||
/// whether the hit is that knockback attack.
|
||||
pub(super) fn extra_knockback(&mut self, target_kind: &str, scale: f32) -> (f32, bool) {
|
||||
let knockback_attack = scale > 0.9 && self.player.as_ref().is_some_and(|p| p.sprinting);
|
||||
let enchanted = match self.inventory[self.selected].clone() {
|
||||
let enchanted = match self.held().cloned() {
|
||||
Some(held) => {
|
||||
let ctx = EffectContext {
|
||||
this: Some(target_kind),
|
||||
@@ -144,7 +148,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// After a successful melee hit: fire aspect and wind burst.
|
||||
pub(super) fn post_attack(&mut self, mob: usize) {
|
||||
let Some(held) = self.inventory[self.selected].clone() else {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(m) = self.mobs.get(mob) else { return };
|
||||
@@ -157,8 +161,10 @@ impl Game<'_> {
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
// minecraft:post_attack ignite effects of the attacker's weapon (fire aspect).
|
||||
// minecraft:post_attack effects of the attacker's weapon on the victim:
|
||||
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
|
||||
let mut ignite = 0.0f32;
|
||||
let mut mob_effects = Vec::new();
|
||||
for (id, level) in held.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
@@ -168,24 +174,33 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["enchanted"] == "attacker"
|
||||
&& effect["affected"] == "victim"
|
||||
&& effect["effect"]["type"] == "minecraft:ignite"
|
||||
let e = &effect["effect"];
|
||||
let kind_of = e["type"].as_str();
|
||||
if effect["enchanted"] != "attacker"
|
||||
|| effect["affected"] != "victim"
|
||||
|| !matches!(
|
||||
kind_of,
|
||||
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
|
||||
)
|
||||
{
|
||||
let mut random = self.world.random.clone();
|
||||
let ok = effect.get("requirements").is_none_or(|r| {
|
||||
self.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.condition(r, *level, &ctx, &mut random)
|
||||
});
|
||||
self.world.random = random;
|
||||
if ok {
|
||||
ignite = ignite.max(horizon_client::enchant::level_value(
|
||||
&effect["effect"]["duration"],
|
||||
*level,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut random = self.world.random.clone();
|
||||
let ok = effect.get("requirements").is_none_or(|r| {
|
||||
self.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.condition(r, *level, &ctx, &mut random)
|
||||
});
|
||||
self.world.random = random;
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
if kind_of == Some("minecraft:ignite") {
|
||||
ignite =
|
||||
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
|
||||
} else {
|
||||
mob_effects.push((e.clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,8 +210,41 @@ impl Game<'_> {
|
||||
{
|
||||
m.ignite_for_ticks((ignite * 20.0) as i32);
|
||||
}
|
||||
for (effect, level) in mob_effects {
|
||||
self.apply_mob_effect(mob, &effect, level);
|
||||
}
|
||||
self.post_attack_bursts(&held, &kind);
|
||||
}
|
||||
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
|
||||
/// random duration and amplifier between the level-based bounds.
|
||||
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
|
||||
use horizon_client::enchant::level_value;
|
||||
let Some(m) = self.mobs.get_mut(mob) else {
|
||||
return;
|
||||
};
|
||||
let ids: Vec<&str> = match &effect["to_apply"] {
|
||||
serde_json::Value::String(id) => vec![id.as_str()],
|
||||
serde_json::Value::Array(list) => list.iter().filter_map(|v| v.as_str()).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
// HolderSet.getRandomElement, then Mth.randomBetween for both values.
|
||||
let pick = m.random.next_int_bound(ids.len() as i32).unwrap_or(0) as usize;
|
||||
let mut between = |min: &str, max: &str| {
|
||||
let (lo, hi) = (
|
||||
level_value(&effect[min], level),
|
||||
level_value(&effect[max], level),
|
||||
);
|
||||
m.random.next_float() * (hi - lo) + lo
|
||||
};
|
||||
let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32;
|
||||
let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0);
|
||||
if let Some(id) = super::potions::mob_effect_id(ids[pick]) {
|
||||
m.add_effect(id, ticks, amplifier);
|
||||
}
|
||||
}
|
||||
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
|
||||
/// a burst at the player's feet that only knocks back and triggers
|
||||
/// blocks (`ExplodeEffect` with `block_interaction: trigger`).
|
||||
@@ -307,8 +355,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
hits.push(1.0 + self.world.random.next_float() * 4.0);
|
||||
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
|
||||
let loss = self.durability_loss(&stack, 2);
|
||||
if let Some(s) = self.armor[slot].as_mut() {
|
||||
s.damage += 2;
|
||||
s.damage += loss;
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
@@ -590,6 +640,173 @@ impl Game<'_> {
|
||||
self.apply_equipment();
|
||||
}
|
||||
}
|
||||
/// `EnchantmentHelper.runLocationChangedEffects` for the worn armor of the
|
||||
/// player, run on the server once the player has moved: `LivingEntity.baseTick`
|
||||
/// runs it when the block position changed, `checkFallDamage` when it lands.
|
||||
/// `from` and `was_on_ground` are where and how the tick began.
|
||||
pub(super) fn location_changed_effects(&mut self, from: [f64; 3], was_on_ground: bool) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let on_ground = p.ground.on_ground;
|
||||
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
|
||||
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
|
||||
return;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let mut disks = Vec::new();
|
||||
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
|
||||
let Some(stack) = stack else { continue };
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !def
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| s == slot || s == "armor" || s == "any")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:location_changed"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["effect"]["type"] == "minecraft:replace_disk"
|
||||
&& location_requirements(&effect["requirements"], on_ground, riding)
|
||||
{
|
||||
disks.push((effect["effect"].clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (effect, level) in disks {
|
||||
self.replace_disk(&effect, level, at);
|
||||
}
|
||||
}
|
||||
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
|
||||
/// predicate become the effect's state, and each placement is a game event.
|
||||
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let spec = &effect["block_state"];
|
||||
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
|
||||
return;
|
||||
};
|
||||
let Some(mut state) = registry.state(default).cloned() else {
|
||||
return;
|
||||
};
|
||||
for (k, v) in spec["properties"].as_object().into_iter().flatten() {
|
||||
state
|
||||
.properties
|
||||
.insert(k.clone(), v.as_str().unwrap_or("").to_owned());
|
||||
}
|
||||
let new = registry.resolve(&state).unwrap_or(default);
|
||||
let radius = level_value(&effect["radius"], level) as i32;
|
||||
let height = level_value(&effect["height"], level) as i32;
|
||||
let offset = cell_offset(effect);
|
||||
let center = [0, 1, 2].map(|i| at[i].floor() as i32 + offset[i]);
|
||||
let event = effect["trigger_game_event"].as_str() == Some("minecraft:block_place");
|
||||
for dy in (height - 1).min(0)..=0 {
|
||||
for dz in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let pos = [center[0] + dx, center[1] + dy, center[2] + dz];
|
||||
let (x, z) = (
|
||||
f64::from(pos[0]) + 0.5 - at[0],
|
||||
f64::from(pos[2]) + 0.5 - at[2],
|
||||
);
|
||||
if x * x + z * z >= f64::from(radius * radius)
|
||||
|| !self.disk_predicate(&effect["predicate"], pos, new)
|
||||
|| self.level_set(pos, new).is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if event {
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_place",
|
||||
pos,
|
||||
Some(Who::Player),
|
||||
Some(new),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The `BlockPredicate` subset the disk effect uses, tested at `pos`; no
|
||||
/// predicate matches everything.
|
||||
fn disk_predicate(&mut self, p: &Value, pos: [i32; 3], placed: BlockStateId) -> bool {
|
||||
if p.is_null() {
|
||||
return true;
|
||||
}
|
||||
let offset = cell_offset(p);
|
||||
let at = [0, 1, 2].map(|i| pos[i] + offset[i]);
|
||||
let state = self.world.block(at[0], at[1], at[2]);
|
||||
let b = self.res.behaviours();
|
||||
let names = |v: &Value| -> Vec<String> {
|
||||
match v {
|
||||
Value::String(s) => vec![s.clone()],
|
||||
Value::Array(a) => a
|
||||
.iter()
|
||||
.filter_map(|n| n.as_str().map(String::from))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
match p["type"].as_str().unwrap_or("") {
|
||||
"minecraft:all_of" => p["predicates"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|q| self.disk_predicate(q, pos, placed)),
|
||||
// `MatchingBlockTagPredicate`
|
||||
"minecraft:matching_block_tag" => b
|
||||
.tag(p["tag"].as_str().unwrap_or("").trim_start_matches('#'))
|
||||
.is_some_and(|t| t.contains(b.block(state))),
|
||||
// `MatchingBlocksPredicate`
|
||||
"minecraft:matching_blocks" => {
|
||||
names(&p["blocks"]).iter().any(|n| n == b.table.name(state))
|
||||
}
|
||||
// `MatchingFluidsPredicate`: Fluids.WATER is the source.
|
||||
"minecraft:matching_fluids" => {
|
||||
let fluid = b.fluid(state);
|
||||
names(&p["fluids"]).iter().any(|n| match n.as_str() {
|
||||
"minecraft:water" => fluid.kind() == FluidKind::Water && fluid.is_source(),
|
||||
"minecraft:flowing_water" => {
|
||||
fluid.kind() == FluidKind::Water && !fluid.is_source()
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
// `UnobstructedPredicate`: no building-blocking entity in the block.
|
||||
"minecraft:unobstructed" => self.with_level(|_, level, _| {
|
||||
level.is_unobstructed(BlockPos::new(pos[0], pos[1], pos[2]), placed)
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Whether a worn enchantment gives immunity to `#burn_from_stepping`
|
||||
/// damage (frost walker's `minecraft:damage_immunity`).
|
||||
pub(crate) fn wears_burn_from_stepping_immunity(&self) -> bool {
|
||||
self.armor
|
||||
.iter()
|
||||
.zip(["head", "chest", "legs", "feet"])
|
||||
.any(|(stack, slot)| {
|
||||
stack
|
||||
.iter()
|
||||
.flat_map(ItemStack::enchantments)
|
||||
.any(|(id, _)| {
|
||||
self.res.stations().enchantments.get(id).is_some_and(|def| {
|
||||
def.effects.get("minecraft:damage_immunity").is_some()
|
||||
&& def
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| s == slot || s == "armor" || s == "any")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
|
||||
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
|
||||
/// so the same enchantment on two pieces stacks instead of replacing itself.
|
||||
@@ -638,3 +855,35 @@ fn soul_speed_modifiers(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An effect's or predicate's `offset` vector (zero when absent).
|
||||
fn cell_offset(v: &Value) -> [i32; 3] {
|
||||
let at = |i: usize| {
|
||||
v["offset"]
|
||||
.get(i)
|
||||
.and_then(Value::as_i64)
|
||||
.map_or(0, |n| n as i32)
|
||||
};
|
||||
[at(0), at(1), at(2)]
|
||||
}
|
||||
/// The loot-condition subset a `location_changed` effect requires of the
|
||||
/// wearer: standing on the ground and not riding (frost walker).
|
||||
fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
|
||||
match req["type"].as_str().unwrap_or("") {
|
||||
"" if req.is_null() => true,
|
||||
"minecraft:all_of" => req["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|t| location_requirements(t, on_ground, riding)),
|
||||
"minecraft:inverted" => !location_requirements(&req["term"], on_ground, riding),
|
||||
"minecraft:entity_properties" => {
|
||||
let p = &req["predicate"];
|
||||
p["minecraft:flags"]["is_on_ground"]
|
||||
.as_bool()
|
||||
.is_none_or(|v| v == on_ground)
|
||||
&& (p["minecraft:vehicle"].is_null() || riding)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
motion::PlayerMotion,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
vanillagen::VanillaTerrain,
|
||||
world_spawn::PlayerSpawnFinder,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
@@ -253,10 +254,17 @@ impl Game<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
/// LightmapRenderStateExtractor: End flashes add to the sky light factor.
|
||||
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
|
||||
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
|
||||
/// third of it under a boss bar's world fog.
|
||||
pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) {
|
||||
if self.dim == Dim::End {
|
||||
sky.sky_light_factor += self.end.flash.intensity(partial as f32);
|
||||
if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") {
|
||||
let intensity = self.end.flash.intensity(partial as f32);
|
||||
sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() {
|
||||
intensity / 3.0
|
||||
} else {
|
||||
intensity
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,7 +529,15 @@ impl Game<'_> {
|
||||
let position = if let Some(job) = self.end.job.take() {
|
||||
self.resolve_gateway(job, target)?
|
||||
} else {
|
||||
let (position, yaw) = self.end_arrival(target)?;
|
||||
let mut finder = self.player_spawn.take();
|
||||
let arrival = self.end_arrival(target, &mut finder);
|
||||
self.player_spawn = finder;
|
||||
let Some((position, yaw)) = arrival? else {
|
||||
// A spawn candidate's chunk is loading: try again next tick.
|
||||
self.travelling = Some((player, target));
|
||||
self.end_travel = true;
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(yaw) = yaw {
|
||||
self.yaw = yaw;
|
||||
player.yaw = yaw;
|
||||
@@ -546,7 +562,13 @@ impl Game<'_> {
|
||||
/// Where an End portal's traveller lands once `target` has loaded, and
|
||||
/// the yaw it faces there: the platform below the End's spawn point,
|
||||
/// facing west, or the top of the Overworld's spawn column.
|
||||
pub(super) fn end_arrival(&mut self, target: Vec3) -> Result<(Vec3, Option<f32>)> {
|
||||
/// None while a candidate of the world spawn's search loads (`finder`
|
||||
/// keeps it, see `search_spawn`).
|
||||
pub(super) fn end_arrival(
|
||||
&mut self,
|
||||
target: Vec3,
|
||||
finder: &mut Option<PlayerSpawnFinder>,
|
||||
) -> Result<Option<(Vec3, Option<f32>)>> {
|
||||
if self.dim == Dim::End {
|
||||
// EndPlatformFeature.createEndPlatform below the spawn point.
|
||||
let origin = [END_SPAWN[0], END_SPAWN[1] - 1, END_SPAWN[2]];
|
||||
@@ -566,22 +588,32 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// Direction.WEST.toYRot().
|
||||
return Ok((target, Some(90.0)));
|
||||
return Ok(Some((target, Some(90.0))));
|
||||
}
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok((at, Some(yaw)));
|
||||
return Ok(Some((at, Some(yaw))));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
|
||||
}
|
||||
let y = self
|
||||
.land_column(target.x.floor() as i32, target.z.floor() as i32)
|
||||
.map_or(target.y, f64::from);
|
||||
Ok((Vec3::new(target.x, y, target.z), None))
|
||||
let (x, z) = (target.x.floor() as i32, target.z.floor() as i32);
|
||||
// TeleportTransition.missingRespawnBlock: PlayerSpawnFinder around
|
||||
// the world spawn.
|
||||
if let Some(world) = self.level.world_spawn.filter(|w| w[0] == x && w[2] == z) {
|
||||
return Ok(match self.search_spawn(world, finder) {
|
||||
Ok(at) => Some((at, None)),
|
||||
Err(chunk) => {
|
||||
self.spawn_wait.push(chunk);
|
||||
None
|
||||
}
|
||||
});
|
||||
}
|
||||
let y = self.land_column(x, z).map_or(target.y, f64::from);
|
||||
Ok(Some((Vec3::new(target.x, y, target.z), None)))
|
||||
}
|
||||
|
||||
/// Dying away from the Overworld respawns at the world spawn there.
|
||||
@@ -599,7 +631,9 @@ impl Game<'_> {
|
||||
|
||||
/// EnderDragonFight without a dragon: once the centre of the main island
|
||||
/// is loaded, place the active exit portal (EndPodiumFeature) and the
|
||||
/// first gateway (spawnNewGateway) if the portal is not there yet.
|
||||
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
|
||||
/// scanState and spawnExitPortal, only while the fight has no exit portal
|
||||
/// location: a podium that is later broken stays broken.
|
||||
fn ensure_exit_portal(&mut self) -> Result<()> {
|
||||
if self.travelling.is_some()
|
||||
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
@@ -609,8 +643,14 @@ impl Game<'_> {
|
||||
}
|
||||
let won = self.fight.previously_killed;
|
||||
let gateway = self.first_gateway();
|
||||
if (0..256).any(|y| &*self.block_name([0, y, 0]) == "minecraft:bedrock") {
|
||||
// The podium exists; the gateway may have been out of range when it was placed.
|
||||
if self.exit_portal().is_none() {
|
||||
// findExitPortal remembers the location of a podium that is already there.
|
||||
if let Some(level) = self.podium_level() {
|
||||
self.set_exit_portal([0, level, 0]);
|
||||
}
|
||||
}
|
||||
if self.exit_portal().is_some() {
|
||||
// The gateway may have been out of range when it was placed.
|
||||
if won
|
||||
&& self.world.loaded(gateway[0], gateway[2])
|
||||
&& &*self.block_name(gateway) != "minecraft:end_gateway"
|
||||
@@ -641,17 +681,40 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The portal level of the podium around the centre column, read from the
|
||||
/// bedrock that EndPodiumFeature places (rim at the portal level, disc
|
||||
/// below it, the torch column) so a broken column does not hide it.
|
||||
fn podium_level(&self) -> Option<i32> {
|
||||
let mut offsets: Vec<[i32; 3]> = (0..4).map(|dy| [0, dy, 0]).collect();
|
||||
for z in -4..=4i32 {
|
||||
for x in -4..=4i32 {
|
||||
let flat = f64::from(x * x + z * z);
|
||||
if (6.25..12.25).contains(&flat) {
|
||||
offsets.push([x, 0, z]);
|
||||
} else if flat + 1.0 < 6.25 {
|
||||
offsets.push([x, -1, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(1..253)
|
||||
.map(|y| {
|
||||
let score = offsets
|
||||
.iter()
|
||||
.filter(|o| &*self.block_name([o[0], y + o[1], o[2]]) == "minecraft:bedrock")
|
||||
.count();
|
||||
(score, y)
|
||||
})
|
||||
.filter(|&(score, _)| score >= 3)
|
||||
.max()
|
||||
.map(|(_, y)| y)
|
||||
}
|
||||
/// `spawnExitPortal(true)` after the dragon dies.
|
||||
pub(super) fn activate_exit_portal(&mut self) -> Result<()> {
|
||||
let origin = match self.exit_portal() {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
// The rim's level: the top of the bedrock column minus three.
|
||||
let top = (0..256)
|
||||
.rev()
|
||||
.find(|&y| &*self.block_name([0, y, 0]) == "minecraft:bedrock")
|
||||
.unwrap_or(64);
|
||||
[0, top - 3, 0]
|
||||
// The rim's level, found without assuming the centre column is intact.
|
||||
[0, self.podium_level().unwrap_or(61), 0]
|
||||
}
|
||||
};
|
||||
self.place_podium(origin, true)
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(super) struct Rocket {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
pub id: u32,
|
||||
pub(super) position: Vec3,
|
||||
previous: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
pub(super) velocity: Vec3,
|
||||
life: i32,
|
||||
lifetime: i32,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Fishing: `FishingRodItem.use` (cast / reel in), the `FishingHook` (flight,
|
||||
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
|
||||
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
|
||||
use super::{Game, view_vector};
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, view_vector};
|
||||
use horizon_client::lan::{entity::States, hub::Hub};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
@@ -14,6 +14,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
@@ -30,8 +31,8 @@ enum State {
|
||||
pub(super) struct Hook {
|
||||
/// Its entity id, which a server's players track it under.
|
||||
id: u32,
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
velocity: Vec3,
|
||||
state: State,
|
||||
life: i32,
|
||||
@@ -45,6 +46,13 @@ pub(super) struct Hook {
|
||||
luck: i32,
|
||||
lure: i32,
|
||||
}
|
||||
/// A player a hook can catch (`canHitEntity`): their entity id, which
|
||||
/// guest they are (none: the host) and their box.
|
||||
pub(super) struct HookTarget {
|
||||
id: u32,
|
||||
conn: Option<u32>,
|
||||
bounds: Aabb,
|
||||
}
|
||||
const SIZE: f64 = 0.25;
|
||||
fn bounds(p: Vec3) -> Aabb {
|
||||
let h = SIZE / 2.0;
|
||||
@@ -166,6 +174,11 @@ impl Game<'_> {
|
||||
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
|
||||
item.velocity = pulled(item.velocity);
|
||||
hooked_item = true;
|
||||
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
|
||||
// A player: the hub moves them when the tick ends.
|
||||
self.creatures
|
||||
.hook_pulls
|
||||
.push((id, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1)));
|
||||
}
|
||||
// `hookedIn instanceof ItemEntity ? 3 : 5`.
|
||||
damage = if hooked_item { 3 } else { 5 };
|
||||
@@ -236,7 +249,7 @@ impl Game<'_> {
|
||||
item,
|
||||
[hook.position.x, hook.position.y, hook.position.z],
|
||||
v,
|
||||
10,
|
||||
0,
|
||||
);
|
||||
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
|
||||
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
|
||||
@@ -318,9 +331,12 @@ impl Game<'_> {
|
||||
let mut speed = 1;
|
||||
let above = [b[0], b[1] + 1, b[2]];
|
||||
let sky = self.world.light(above[0], above[1], above[2]) >> 4 == 15;
|
||||
let raining = self.world.weather.raining;
|
||||
let r = &mut self.world.random;
|
||||
if r.next_float() < 0.25 && raining && sky {
|
||||
// Level.isRainingAt: rain (not snow) reaches the position.
|
||||
if self.world.random.next_float() < 0.25
|
||||
&& self.with_level(|b, level, _| {
|
||||
b.is_raining_at(level, BlockPos::new(above[0], above[1], above[2]))
|
||||
})
|
||||
{
|
||||
speed += 1;
|
||||
}
|
||||
let r = &mut self.world.random;
|
||||
@@ -449,6 +465,15 @@ impl Game<'_> {
|
||||
if !self.holds_rod() || d2 > 1024.0 {
|
||||
return;
|
||||
}
|
||||
// The players it can catch, as they stand now (the host's own hook;
|
||||
// guests' hooks use the ones taken as their turns start).
|
||||
if self.lan_acting.is_none()
|
||||
&& matches!(h.state, State::Flying | State::Hooked(_))
|
||||
&& let Some(host) = self.lan.take()
|
||||
{
|
||||
self.refresh_hook_targets(&host);
|
||||
self.lan = Some(host);
|
||||
}
|
||||
h.previous = h.position;
|
||||
if h.on_ground {
|
||||
h.life += 1;
|
||||
@@ -474,8 +499,8 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: hook the first living mob it touches, or (via
|
||||
// `canHitEntity`) a dropped item.
|
||||
// checkCollision: hook the first living mob or other player it
|
||||
// touches, or (via `canHitEntity`) a dropped item.
|
||||
let bb = bounds(h.position);
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let touches = |b: Aabb| {
|
||||
@@ -487,6 +512,14 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|m| m.alive() && touches(m.bounds()))
|
||||
.map(|m| m.id)
|
||||
.or_else(|| {
|
||||
let owner = self.lan_acting;
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.find(|t| t.conn != owner && touches(t.bounds))
|
||||
.map(|t| t.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -501,12 +534,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
|
||||
// updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height.
|
||||
let hooked = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.alive())
|
||||
.map(|m| (m.position, f64::from(m.height())))
|
||||
.or_else(|| {
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| {
|
||||
let (lo, hi) = (t.bounds.min_position(), t.bounds.max_position());
|
||||
(
|
||||
Vec3::new((lo.x + hi.x) / 2.0, lo.y, (lo.z + hi.z) / 2.0),
|
||||
hi.y - lo.y,
|
||||
)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -575,6 +621,59 @@ impl Game<'_> {
|
||||
);
|
||||
self.hook = Some(h);
|
||||
}
|
||||
/// The players a hook can catch: everyone but spectators and the dead
|
||||
/// (`Projectile.canHitEntity`), the host and the guests in the level.
|
||||
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
|
||||
let mut targets = Vec::new();
|
||||
if self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
targets.push(HookTarget {
|
||||
id: host.host_entity as u32,
|
||||
conn: None,
|
||||
bounds: p.bounds(),
|
||||
});
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
|
||||
targets.push(HookTarget {
|
||||
id: g.entity_id as u32,
|
||||
conn: Some(g.conn),
|
||||
bounds: guest_bounds(g),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.creatures.hook_targets = targets;
|
||||
}
|
||||
/// `FishingHook.pullEntity` on the players the hooks reeled in this tick
|
||||
/// pulled: their velocity moves, and a guest's reaches their client.
|
||||
pub(super) fn flush_hook_pulls(&mut self) {
|
||||
let pulls = std::mem::take(&mut self.creatures.hook_pulls);
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
for (id, delta) in pulls {
|
||||
let push = |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(delta);
|
||||
}
|
||||
};
|
||||
if id == host.host_entity as u32 {
|
||||
push(self);
|
||||
} else if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id as u32 == id)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
self.guest_hit_by(&mut host, conn, push);
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
/// `FishingRodCast`: the stack in the arm holding the rod
|
||||
/// (`FishingHookRenderer.getHoldingArm`) while the hook is out.
|
||||
pub(super) fn cast_rod(&self) -> Option<&ItemStack> {
|
||||
@@ -770,8 +869,8 @@ impl Game<'_> {
|
||||
pub(super) struct RemoteHook {
|
||||
id: u32,
|
||||
owner: u32,
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
}
|
||||
|
||||
impl Hook {
|
||||
|
||||
@@ -7,6 +7,7 @@ use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary
|
||||
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -223,6 +224,7 @@ impl Game<'_> {
|
||||
}
|
||||
let p = self.item_frames[i].pos;
|
||||
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
|
||||
self.frame_output_changed(p);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
@@ -255,8 +257,17 @@ impl Game<'_> {
|
||||
}
|
||||
self.drop_frame_items(&f, with_frame, true);
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
|
||||
/// the comparators reading the frame take its new signal.
|
||||
fn frame_output_changed(&mut self, pos: [i32; 3]) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
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: &ItemFrame, with_frame: bool, by_player: bool) {
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
|
||||
@@ -300,6 +311,9 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&f, true, false);
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -456,7 +470,7 @@ impl Game<'_> {
|
||||
}
|
||||
for (id, m, light) in maps {
|
||||
let entity_start = out.len();
|
||||
self.map_vertices(id, m, light, out);
|
||||
self.map_vertices(id, true, m, light, out);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Glowing entities (`EntityRenderer.extractRenderState`'s outlineColor): which entities
|
||||
//! appear glowing, in which team colour, and their silhouettes for the renderer's entity
|
||||
//! outline (`OutlineBufferSource`).
|
||||
use super::{Game, Vertex};
|
||||
use horizon_client::{mobs::Mob, scoreboard as sb};
|
||||
|
||||
/// `Entity.getTeamColor`: the team's colour, else white.
|
||||
pub(super) fn team_color(team: Option<&sb::Team>) -> u32 {
|
||||
team.and_then(|t| t.color.as_deref())
|
||||
.and_then(sb::team_color_rgb)
|
||||
.unwrap_or(0xFF_FFFF)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.isCurrentlyGlowing` of an entity that is not living (an item, a projectile):
|
||||
/// its `Glowing` tag, or shared flag 6 as a server sent it.
|
||||
pub(super) fn entity_glowing(&self, id: u32) -> bool {
|
||||
self.remote_glowing(id).unwrap_or_else(|| {
|
||||
self.persist_raw(id)
|
||||
.and_then(|t| horizon_client::save::number(t.get("Glowing")))
|
||||
.is_some_and(|b| b != 0)
|
||||
})
|
||||
}
|
||||
|
||||
/// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline
|
||||
/// colour (`getTeamColor`; another player's team goes by name).
|
||||
pub(super) fn mob_outline(&self, m: &Mob) -> Option<u32> {
|
||||
if !m.is_currently_glowing() {
|
||||
return None;
|
||||
}
|
||||
let team = if &*m.kind.id == "minecraft:player" {
|
||||
let names = self.remote_player_names();
|
||||
let name = names.into_iter().find(|(id, _)| *id == m.id);
|
||||
name.and_then(|(_, name)| self.score.board.players_team(&name))
|
||||
} else {
|
||||
self.mob_team(m.id)
|
||||
};
|
||||
Some(team_color(team))
|
||||
}
|
||||
|
||||
/// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures'
|
||||
/// alpha kept (`OutlineBufferSource`'s team-coloured copy of each draw).
|
||||
pub(super) fn mark_outline(&self, vertices: &[Vertex], rgb: u32) {
|
||||
let color = horizon_client::mobs::render::abgr(rgb);
|
||||
self.glowing_vertices
|
||||
.borrow_mut()
|
||||
.extend(vertices.iter().map(|v| Vertex {
|
||||
color,
|
||||
normal: [0; 4],
|
||||
..*v
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing
|
||||
//! (`LivingEntity.SwingState`) and the first-person equip heights
|
||||
//! (`FirstPersonHandsAndItems`) with the item swap ticker they read.
|
||||
use super::{Game, Mining, Screen};
|
||||
use super::{Game, Mining, Screen, minecarts::CartKind};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
attack_strength::HeldStack,
|
||||
@@ -144,6 +144,11 @@ impl Game<'_> {
|
||||
let harvest = !facts.requires_tool || self.res.items().correct_for_drops(held, &facts.name);
|
||||
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
|
||||
}
|
||||
/// A key for the main-hand stack (`destroyingItem`): its item and components.
|
||||
pub(super) fn main_hand_key(&self) -> String {
|
||||
self.held()
|
||||
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
|
||||
}
|
||||
/// Whether an attack would hit an entity rather than the block behind
|
||||
/// it: the crosshair's `EntityHitResult`.
|
||||
pub(super) fn entity_targeted(&self) -> bool {
|
||||
@@ -289,12 +294,79 @@ impl Game<'_> {
|
||||
// Only alive mobs are hit, so a picked living entity is alive.
|
||||
Some(true)
|
||||
}
|
||||
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
|
||||
/// `Hud.canRenderCrosshairForSpectator` for the nearest hit in reach: an
|
||||
/// entity that is a `MenuProvider` (a chest or hopper minecart, a chest
|
||||
/// boat), or a block whose `showAsInteractableInSpectatorMode` holds.
|
||||
pub(super) fn spectator_crosshair(&self) -> bool {
|
||||
let cart = self.cart_hit().map(|(i, d)| {
|
||||
let kind = &self.carts[i].kind;
|
||||
(d, matches!(kind, CartKind::Chest | CartKind::Hopper))
|
||||
});
|
||||
let boat = self.boat_hit().map(|(i, d)| (d, self.boats[i].chest));
|
||||
let others = [
|
||||
self.mob_hit().map(|h| h.1),
|
||||
self.armor_stand_hit().map(|h| h.1),
|
||||
self.cushion_hit().map(|h| h.1),
|
||||
self.painting_hit().map(|h| h.1),
|
||||
self.frame_hit().map(|h| h.1),
|
||||
self.dragon_hit().map(|h| h.1),
|
||||
];
|
||||
let nearest = cart
|
||||
.into_iter()
|
||||
.chain(boat)
|
||||
.chain(others.into_iter().flatten().map(|d| (d, false)))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if let Some((distance, menu)) = nearest
|
||||
&& self.before_block(distance)
|
||||
{
|
||||
return menu;
|
||||
}
|
||||
let Some(hit) = self.pick() else {
|
||||
return false;
|
||||
};
|
||||
let name = &*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
let name = name.trim_start_matches("minecraft:");
|
||||
match name {
|
||||
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
|
||||
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
|
||||
| "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table"
|
||||
| "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper"
|
||||
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
|
||||
| "end_portal" | "end_gateway" => true,
|
||||
// ChestBlock.getMenuProvider: none while a redstone conductor
|
||||
// sits on top of either half (a sitting cat aside).
|
||||
"chest" | "trapped_chest" => {
|
||||
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let blocked = |p: (i32, i32, i32)| {
|
||||
let above = self.world.block(p.0, p.1 + 1, p.2);
|
||||
self.res.behaviours().facts(above).flags
|
||||
& horizon_world::level::data::state_flags::CONDUCTOR
|
||||
!= 0
|
||||
};
|
||||
!blocked(key) && self.chest_other_half(key).is_none_or(|o| !blocked(o))
|
||||
}
|
||||
_ => name.ends_with("shulker_box"),
|
||||
}
|
||||
}
|
||||
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier and the
|
||||
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
|
||||
/// the attribute's range.
|
||||
pub(super) fn main_hand_attack_speed(&self) -> f64 {
|
||||
horizon_client::attack_strength::BASE_ATTACK_SPEED
|
||||
let value = horizon_client::attack_strength::BASE_ATTACK_SPEED
|
||||
+ self
|
||||
.attack_item_info()
|
||||
.map_or(0.0, |i| f64::from(i.attack_speed))
|
||||
.map_or(0.0, |i| f64::from(i.attack_speed));
|
||||
self.potions
|
||||
.effects
|
||||
.modifiers()
|
||||
.into_iter()
|
||||
.filter(|m| m.0 == "minecraft:attack_speed")
|
||||
.fold(value, |v, m| v * (1.0 + m.2))
|
||||
.clamp(0.0, 1024.0)
|
||||
}
|
||||
/// The item whose attack damage and speed modifiers the player has (`attack_item`).
|
||||
pub(super) fn attack_item_info(&self) -> Option<&horizon_client::items::ItemInfo> {
|
||||
@@ -445,9 +517,7 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
true
|
||||
}
|
||||
fn main_hand_key(&mut self) -> String {
|
||||
self.game
|
||||
.held()
|
||||
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
|
||||
self.game.main_hand_key()
|
||||
}
|
||||
fn destroy_stage(&mut self, _: [i32; 3], _: Option<i32>) {}
|
||||
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
|
||||
|
||||
@@ -212,39 +212,58 @@ impl Game<'_> {
|
||||
)
|
||||
.is_ok_and(|shape| shape.is_empty())
|
||||
}
|
||||
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
|
||||
/// player who has no position yet. None while a candidate's chunk loads.
|
||||
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
|
||||
let mut finder = match self.player_spawn {
|
||||
Some(finder) => finder,
|
||||
None => self.spawn_finder(suggestion),
|
||||
};
|
||||
let found = loop {
|
||||
/// PlayerSpawnFinder.findSpawn around the world spawn, as far as the loaded
|
||||
/// chunks allow: the feet of the first free column within the respawn
|
||||
/// radius, else the suggestion's own column. The finder waits in `state`
|
||||
/// (None to begin); Err gives the chunk of the candidate being waited for,
|
||||
/// which the caller must have loaded (the finder tries at most 1024
|
||||
/// columns).
|
||||
pub(super) fn search_spawn(
|
||||
&self,
|
||||
suggestion: [i32; 3],
|
||||
state: &mut Option<PlayerSpawnFinder>,
|
||||
) -> std::result::Result<Vec3, (i32, i32)> {
|
||||
let mut finder = state
|
||||
.take()
|
||||
.unwrap_or_else(|| self.spawn_finder(suggestion));
|
||||
let suggestion = finder.suggestion;
|
||||
loop {
|
||||
let Some(candidate) = finder.next_candidate() else {
|
||||
break Some(self.fixup_spawn_height(suggestion));
|
||||
return Ok(self.fixup_spawn_height(suggestion));
|
||||
};
|
||||
let (x, z) = match candidate {
|
||||
SpawnCandidate::Column(x, z) => (x, z),
|
||||
SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]),
|
||||
};
|
||||
if !self.streaming.is_lit(x >> 4, z >> 4) {
|
||||
self.streaming.center = (x >> 4, z >> 4);
|
||||
finder.retry();
|
||||
break None;
|
||||
*state = Some(finder);
|
||||
return Err((x >> 4, z >> 4));
|
||||
}
|
||||
match candidate {
|
||||
SpawnCandidate::Column(x, z) => {
|
||||
if let Some(pos) = level_respawn_pos(&self.spawn_columns(), x, z)
|
||||
&& self.player_fits(pos)
|
||||
{
|
||||
break Some(bottom_center(pos));
|
||||
return Ok(bottom_center(pos));
|
||||
}
|
||||
}
|
||||
SpawnCandidate::Suggestion => break Some(self.fixup_spawn_height(suggestion)),
|
||||
SpawnCandidate::Suggestion => return Ok(self.fixup_spawn_height(suggestion)),
|
||||
}
|
||||
};
|
||||
self.player_spawn = found.is_none().then_some(finder);
|
||||
Ok(found)
|
||||
}
|
||||
}
|
||||
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
|
||||
/// player who has no position yet. None while a candidate's chunk loads.
|
||||
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
|
||||
let mut state = self.player_spawn.take();
|
||||
match self.search_spawn(suggestion, &mut state) {
|
||||
Ok(feet) => Ok(Some(feet)),
|
||||
Err(chunk) => {
|
||||
self.streaming.center = chunk;
|
||||
self.player_spawn = state;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut
|
||||
/// by the world border; the suggestion itself in adventure mode.
|
||||
|
||||
@@ -164,12 +164,18 @@ pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> Owner
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food.
|
||||
/// `UseDuration` of a stack (`Item.getUseDuration`): 72000 for drawn items
|
||||
/// and for `blocks_attacks` items (shields), the consume time for food.
|
||||
fn use_duration(components: &Components) -> i32 {
|
||||
if let Some(c) = components.get("minecraft:consumable") {
|
||||
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
|
||||
return (seconds * 20.0) as i32;
|
||||
}
|
||||
if components.get("minecraft:blocks_attacks").is_some()
|
||||
|| components.get("minecraft:kinetic_weapon").is_some()
|
||||
{
|
||||
return 72_000;
|
||||
}
|
||||
let item_model = components
|
||||
.get("minecraft:item_model")
|
||||
.and_then(Value::as_str)
|
||||
@@ -179,8 +185,9 @@ fn use_duration(components: &Components) -> i32 {
|
||||
| "minecraft:crossbow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:spyglass"
|
||||
| "minecraft:goat_horn"
|
||||
| "minecraft:brush" => 72_000,
|
||||
| "minecraft:goat_horn" => 72_000,
|
||||
// BrushItem.USE_DURATION.
|
||||
"minecraft:brush" => 200,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -247,6 +254,11 @@ impl Game<'_> {
|
||||
owner.main_arm_left = self.settings.main_hand_left;
|
||||
owner.is_local_player = true;
|
||||
owner.is_view_entity = true;
|
||||
owner.last_death_location = self
|
||||
.level
|
||||
.last_death
|
||||
.clone()
|
||||
.map(|(dimension, pos)| GlobalPos { dimension, pos });
|
||||
if let (Some((slot, ticks)), Some(stack)) = (self.using, stack)
|
||||
&& self.stack_in(slot) == Some(stack)
|
||||
{
|
||||
@@ -405,7 +417,12 @@ impl Game<'_> {
|
||||
) {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
}
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
if self.entity_glowing(item.id) {
|
||||
let rgb = super::glowing::team_color(self.mob_team(item.id));
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,15 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Jukebox},
|
||||
items::ItemStack,
|
||||
mobs::Species,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -94,7 +99,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
|
||||
fn start_song(&mut self, pos: [i32; 3], song: &str) {
|
||||
self.stop_song(pos);
|
||||
self.stop_sound(pos);
|
||||
let Some(s) = self.res.items().songs.get(song).cloned() else {
|
||||
return;
|
||||
};
|
||||
@@ -106,12 +111,38 @@ impl Game<'_> {
|
||||
self.titles
|
||||
.set_overlay(Text::translate("record.nowPlaying", &[description]), true);
|
||||
self.update_song_volumes();
|
||||
self.notify_record_playing(pos, true);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_songs.push((pos, Some(song.to_owned())));
|
||||
}
|
||||
}
|
||||
fn stop_song(&mut self, pos: [i32; 3]) {
|
||||
fn stop_sound(&mut self, pos: [i32; 3]) {
|
||||
if let Some(id) = self.jukebox_sounds.remove(&(pos[0], pos[1], pos[2])) {
|
||||
self.audio.stop_and_release(id);
|
||||
}
|
||||
}
|
||||
/// Level event 1011: the song ends, here and for the guests.
|
||||
fn stop_song(&mut self, pos: [i32; 3]) {
|
||||
self.stop_sound(pos);
|
||||
self.notify_record_playing(pos, false);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_songs.push((pos, None));
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler.notifyNearbyEntities` (level events 1010 and 1011):
|
||||
/// parrots within three blocks of the jukebox start or stop dancing.
|
||||
pub(super) fn notify_record_playing(&mut self, pos: [i32; 3], playing: bool) {
|
||||
let min = Vec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
|
||||
let area = Aabb::new(min, min.add_vector(Vec3::new(1.0, 1.0, 1.0)))
|
||||
.inflate(Vec3::new(3.0, 3.0, 3.0));
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.species() == Species::Parrot && m.bounds().intersects(area))
|
||||
{
|
||||
m.extra.party_jukebox = playing.then_some(pos);
|
||||
}
|
||||
}
|
||||
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
|
||||
fn eject_record(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
mobs::{Mob, Species, Who, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -140,6 +140,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:hoglin"
|
||||
| "minecraft:zoglin"
|
||||
| "minecraft:goat"
|
||||
| "minecraft:ocelot"
|
||||
| "minecraft:turtle"
|
||||
@@ -191,10 +192,18 @@ fn mob_variant_meta(
|
||||
"minecraft:zombie_nautilus_variant",
|
||||
),
|
||||
};
|
||||
// Each registry's default (`define(DATA_VARIANT_ID, ... TEMPERATE)`)
|
||||
// stays out of the pairing data, as other default values do.
|
||||
let name = horizon_client::mobs::finalize::variant_name(m.species(), e.variant);
|
||||
if let Some(v) = id(registry, name.trim_start_matches("minecraft:")) {
|
||||
let name = name.trim_start_matches("minecraft:");
|
||||
if let Some(v) = id(registry, name)
|
||||
&& name != "temperate"
|
||||
{
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
if let Some(v) = id(registry, "temperate") {
|
||||
defaults.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
}
|
||||
// Rabbit.DATA_TYPE_ID (after AgeableMob's two) and
|
||||
// Parrot.DATA_VARIANT_ID (after TamableAnimal's two and its flags and
|
||||
@@ -288,8 +297,27 @@ fn mob_meta(
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
// Entity.DATA_SHARED_FLAGS_ID: on fire 1, swimming 16 (a drowned under
|
||||
// water, `Drowned.setSwimming`), invisible 32 (the effect's flag) and
|
||||
// glowing 64 (LivingEntity.updateGlowingStatus).
|
||||
let shared = i8::from(m.is_on_fire())
|
||||
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
|
||||
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
|
||||
| i8::from(m.is_currently_glowing()) << 6;
|
||||
if shared != 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(shared)));
|
||||
}
|
||||
// Entity.DATA_POSE: the pose the species keeps in its own fields (a
|
||||
// pose back at standing goes out by the entity's own default).
|
||||
let pose = horizon_client::mobs::sync::pose_id(m);
|
||||
if pose != 0 {
|
||||
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
|
||||
}
|
||||
// LivingEntity.DATA_SLEEPING_POS_ID: the bed a sleeping villager lies in.
|
||||
if horizon_client::mobs::villager::sleeping(m)
|
||||
&& let Some(bed) = m.extra.bed
|
||||
{
|
||||
meta.push((14, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(Some(bed))));
|
||||
}
|
||||
if let Some(name) = &m.custom_name {
|
||||
meta.push((
|
||||
@@ -385,8 +413,14 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
|
||||
// AbstractPiglin.DATA_IMMUNE_TO_ZOMBIFICATION (with the dimension it
|
||||
// makes them shake) and Piglin.DATA_IS_DANCING (after the baby flag
|
||||
// and the crossbow charge).
|
||||
"minecraft:piglin" => {
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if e.scratch[0] > 0 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
@@ -400,6 +434,25 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Fox.DATA_TYPE_ID and DATA_FLAGS_ID (after AgeableMob's two; sleeping 32, ...).
|
||||
"minecraft:fox" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
let flags = (e.ints[0] & 0xff) as u8 as i8;
|
||||
if flags != 0 {
|
||||
meta.push((19, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
defaults.push((19, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
"minecraft:parrot" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
|
||||
if flags != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
}
|
||||
"minecraft:cat" | "minecraft:wolf" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
|
||||
@@ -421,6 +474,15 @@ fn mob_meta(
|
||||
if let Some(v) = id(registry, name) {
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
// Cat.IS_LYING and RELAX_STATE_ONE.
|
||||
if kind == "minecraft:cat" {
|
||||
for (index, on) in [(21, e.flag), (22, e.relax_one)] {
|
||||
if on {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
}
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
|
||||
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
|
||||
let red = i32::from(horizon_client::mobs::tame::RED);
|
||||
@@ -445,10 +507,16 @@ fn mob_meta(
|
||||
| "minecraft:skeleton_horse"
|
||||
| "minecraft:zombie_horse"
|
||||
| "minecraft:camel" => {
|
||||
// AbstractHorse.DATA_ID_FLAGS: tame 2.
|
||||
if e.tame {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(2)));
|
||||
// AbstractHorse.DATA_ID_FLAGS: tame 2, eating 16 (grazing), standing
|
||||
// 32 (rearing).
|
||||
let grazes = horizon_client::mobs::equine::grazes(m.species());
|
||||
let flags = if e.tame { 2 } else { 0 }
|
||||
| if grazes && e.flag { 16 } else { 0 }
|
||||
| if grazes && e.counter > 0 { 32 } else { 0 };
|
||||
if flags != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
if kind == "minecraft:horse" {
|
||||
let v = i32::from(e.variant) | i32::from(e.variant2) << 8;
|
||||
if v != 0 {
|
||||
@@ -473,6 +541,15 @@ fn mob_meta(
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
|
||||
"minecraft:villager" | "minecraft:zombie_villager" => {
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
if kind == "minecraft:villager" {
|
||||
unhappy_counter(m, meta, defaults);
|
||||
} else {
|
||||
// ZombieVillager.DATA_CONVERTING_ID: the cure's shaking.
|
||||
if horizon_client::mobs::zombie_villager::is_converting(m) {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
let villager_type = VILLAGER_TYPES[e.variant as usize % VILLAGER_TYPES.len()];
|
||||
let profession = PROFESSIONS[e.variant2 as usize % PROFESSIONS.len()];
|
||||
if let (Some(t), Some(p)) = (
|
||||
@@ -514,6 +591,29 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Panda: UNHAPPY_COUNTER, SNEEZE_COUNTER and EAT_COUNTER (after
|
||||
// AgeableMob's two), MAIN_GENE_ID, HIDDEN_GENE_ID and DATA_ID_FLAGS;
|
||||
// the client animates the states from them.
|
||||
"minecraft:panda" => {
|
||||
let (flags, counters) = horizon_client::mobs::panda::get(m)
|
||||
.map_or((0, [0; 3]), |s| (s.flags, [s.unhappy, s.sneeze, s.eat]));
|
||||
for (index, value) in (18..).zip(counters) {
|
||||
if value != 0 {
|
||||
meta.push((index, serializer::INT, Value::Int(value)));
|
||||
}
|
||||
defaults.push((index, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
for (index, gene) in [(21, e.variant), (22, e.variant2)] {
|
||||
if gene != 0 {
|
||||
meta.push((index, serializer::BYTE, Value::Byte(gene as i8)));
|
||||
}
|
||||
defaults.push((index, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
if flags != 0 {
|
||||
meta.push((23, serializer::BYTE, Value::Byte(flags as i8)));
|
||||
}
|
||||
defaults.push((23, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
|
||||
// rears up; the client animates it.
|
||||
"minecraft:polar_bear" => {
|
||||
@@ -529,7 +629,122 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE.
|
||||
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
|
||||
"minecraft:warden" => {
|
||||
let anger = e.warden.as_ref().map_or(0, |w| w.client_anger);
|
||||
if anger != 0 {
|
||||
meta.push((16, serializer::INT, Value::Int(anger)));
|
||||
}
|
||||
defaults.push((16, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Sniffer.DATA_STATE (after AgeableMob's two).
|
||||
"minecraft:sniffer" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((
|
||||
18,
|
||||
serializer::SNIFFER_STATE,
|
||||
Value::Id(i32::from(e.variant)),
|
||||
));
|
||||
}
|
||||
defaults.push((18, serializer::SNIFFER_STATE, Value::Id(0)));
|
||||
}
|
||||
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID (16
|
||||
// for none).
|
||||
"minecraft:shulker" => {
|
||||
if e.variant3 != 0 {
|
||||
meta.push((16, serializer::DIRECTION, Value::Id(i32::from(e.variant3))));
|
||||
}
|
||||
defaults.push((16, serializer::DIRECTION, Value::Id(0)));
|
||||
if e.counter != 0 {
|
||||
meta.push((17, serializer::BYTE, Value::Byte(e.counter as i8)));
|
||||
}
|
||||
defaults.push((17, serializer::BYTE, Value::Byte(0)));
|
||||
if e.color != 16 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(e.color as i8)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(16)));
|
||||
}
|
||||
// Vex.DATA_FLAGS_ID: charging in bit 0 (the arms and the glowing
|
||||
// texture).
|
||||
"minecraft:vex" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
|
||||
"minecraft:creaking" => {
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
if e.flag {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if horizon_client::mobs::creaking::tearing_down(m) {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Allay.DATA_DANCING and DATA_CAN_DUPLICATE (the held item is its
|
||||
// main hand equipment).
|
||||
"minecraft:allay" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if e.egg_time > 0 {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
// Axolotl.DATA_PLAYING_DEAD (after DATA_VARIANT).
|
||||
"minecraft:axolotl" => {
|
||||
if horizon_client::mobs::axolotl::is_playing_dead(m) {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// CopperGolem.COPPER_GOLEM_STATE (after DATA_WEATHER_STATE).
|
||||
"minecraft:copper_golem" => {
|
||||
if e.variant3 != 0 {
|
||||
meta.push((
|
||||
17,
|
||||
serializer::COPPER_GOLEM_STATE,
|
||||
Value::Id(i32::from(e.variant3)),
|
||||
));
|
||||
}
|
||||
defaults.push((17, serializer::COPPER_GOLEM_STATE, Value::Id(0)));
|
||||
}
|
||||
// Armadillo.ARMADILLO_STATE (after AgeableMob's two).
|
||||
"minecraft:armadillo" => {
|
||||
let state = horizon_client::mobs::armadillo::state(m);
|
||||
if state != 0 {
|
||||
meta.push((18, serializer::ARMADILLO_STATE, Value::Id(i32::from(state))));
|
||||
}
|
||||
defaults.push((18, serializer::ARMADILLO_STATE, Value::Id(0)));
|
||||
}
|
||||
// Frog.DATA_TONGUE_TARGET_ID (after the variant): the entity id plus
|
||||
// one, 0 for none.
|
||||
"minecraft:frog" => {
|
||||
if let Some(target) = e.frog.tongue_target {
|
||||
let id = i32::try_from(target).map_or(0, |t| t + 1);
|
||||
meta.push((19, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(id)));
|
||||
}
|
||||
defaults.push((19, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(0)));
|
||||
}
|
||||
// Goat.DATA_HAS_LEFT_HORN and DATA_HAS_RIGHT_HORN (after
|
||||
// DATA_IS_SCREAMING_GOAT): both true by default.
|
||||
"minecraft:goat" => {
|
||||
for (index, lost) in [(19, e.lost_horns[0]), (20, e.lost_horns[1])] {
|
||||
if lost {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE and DATA_CREEPY (angry: the open mouth).
|
||||
"minecraft:enderman" => {
|
||||
if let Some(state) = e.carried {
|
||||
meta.push((
|
||||
@@ -538,9 +753,112 @@ fn mob_meta(
|
||||
Value::Id(state.0 as i32),
|
||||
));
|
||||
}
|
||||
if e.flag {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((16, serializer::OPTIONAL_BLOCK_STATE, Value::Id(0)));
|
||||
}
|
||||
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
|
||||
// DATA_SPECIAL_TYPE_ID): the shaking of the conversion under water.
|
||||
"minecraft:zombie" | "minecraft:husk" => {
|
||||
if e.counter2 > 0 {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
|
||||
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
|
||||
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
|
||||
// AbstractPiglin's, the first entry of its subclasses: with the
|
||||
// dimension it makes them shake.
|
||||
"minecraft:hoglin" | "minecraft:piglin_brute" => {
|
||||
let index = if kind == "minecraft:hoglin" { 18 } else { 16 };
|
||||
if e.flag2 {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
|
||||
"minecraft:blaze" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
|
||||
"minecraft:turtle" => {
|
||||
if e.flag {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if e.flag2 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// WitherBoss.DATA_TARGET_B and DATA_TARGET_C (after DATA_TARGET_A, the
|
||||
// main target): the entities the side heads look at, by id.
|
||||
"minecraft:wither" => {
|
||||
for (k, index) in [(0, 17), (1, 18)] {
|
||||
let target = match e.ints[k] {
|
||||
0 => None,
|
||||
-1 => player_entity,
|
||||
id => Some(id),
|
||||
};
|
||||
if let Some(target) = target {
|
||||
meta.push((index, serializer::INT, Value::Int(target)));
|
||||
}
|
||||
defaults.push((index, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's flags).
|
||||
if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
if e.flag2 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
let tick = horizon_client::mobs::camel::synced_pose_tick(m, game_time);
|
||||
if tick != 0 {
|
||||
meta.push((20, serializer::LONG, Value::Long(tick)));
|
||||
}
|
||||
defaults.push((20, serializer::LONG, Value::Long(0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
|
||||
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
|
||||
/// AgeableMob's two and DATA_BOOST_TIME).
|
||||
fn mob_freeze_meta(
|
||||
m: &Mob,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
if m.ticks_frozen > 0 {
|
||||
meta.push((7, serializer::INT, Value::Int(m.ticks_frozen)));
|
||||
}
|
||||
defaults.push((7, serializer::INT, Value::Int(0)));
|
||||
if m.kind.id == "minecraft:strider" {
|
||||
if m.extra.flag {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
}
|
||||
|
||||
/// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two): the head
|
||||
/// shake of a villager saying no counts it down from 40.
|
||||
fn unhappy_counter(
|
||||
m: &Mob,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
if m.extra.ints[0] > 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(m.extra.ints[0])));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
|
||||
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
|
||||
@@ -551,21 +869,33 @@ fn mob_equipment(
|
||||
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
|
||||
) {
|
||||
let e = &m.extra;
|
||||
// A horse's armour, a llama's carpet, a harness: Horizon keeps the body
|
||||
// slot's item (6) in `head`.
|
||||
let body = horizon_client::mobs::drops::head_is_body(m.species());
|
||||
let slots = [
|
||||
(0u8, &e.main_hand),
|
||||
(1, &e.off_hand),
|
||||
(2, &e.feet),
|
||||
(3, &e.legs),
|
||||
(4, &e.chest),
|
||||
(5, &e.head),
|
||||
(if body { 6 } else { 5 }, &e.head),
|
||||
];
|
||||
for (slot, item) in slots {
|
||||
if let Some(item) = item {
|
||||
let stack = ItemStack {
|
||||
item: item.clone(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
// A wolf's armor shows its cracks.
|
||||
damage: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
|
||||
e.body_damage.max(0) as u32
|
||||
} else {
|
||||
0
|
||||
},
|
||||
extra: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
|
||||
e.body_extra.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
out.push((slot, hub.stack(&Some(stack))));
|
||||
}
|
||||
@@ -846,6 +1176,7 @@ impl Game<'_> {
|
||||
self.world.light_log = None;
|
||||
self.audio.capture = None;
|
||||
self.lan_events.clear();
|
||||
self.lan_songs.clear();
|
||||
self.lan_critical_hits.clear();
|
||||
self.streaming.extra.clear();
|
||||
if announce {
|
||||
@@ -1067,6 +1398,7 @@ impl Game<'_> {
|
||||
&mut e.meta,
|
||||
&mut e.defaults,
|
||||
);
|
||||
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
@@ -1077,6 +1409,10 @@ impl Game<'_> {
|
||||
e.position = [d.position.x, d.position.y, d.position.z];
|
||||
e.on_ground = d.on_ground;
|
||||
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
|
||||
// Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag).
|
||||
if self.entity_glowing(d.id) {
|
||||
e.meta.push((0, serializer::BYTE, Value::Byte(0x40)));
|
||||
}
|
||||
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
|
||||
}
|
||||
for o in &self.orbs {
|
||||
@@ -1183,6 +1519,8 @@ impl Game<'_> {
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
self.ui.rules.bool("elytra_movement_check"),
|
||||
);
|
||||
host.hub
|
||||
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
|
||||
// A server with several levels runs a pass for each
|
||||
// (`tick_server`): the network is read once, before the first.
|
||||
let pass = self.level_pass;
|
||||
@@ -1235,6 +1573,16 @@ impl Game<'_> {
|
||||
"INFO",
|
||||
&format!("System chat: {} joined the game", g.name),
|
||||
);
|
||||
} else if self.player.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
&& g.placed
|
||||
{
|
||||
// The host's chat hears of the newcomer as the guests do.
|
||||
let who = self.player_text(&g.name, g.uuid);
|
||||
self.system_message(
|
||||
Text::translate("multiplayer.player.joined", &[who])
|
||||
.with_color(horizon_client::text::YELLOW),
|
||||
);
|
||||
}
|
||||
}
|
||||
HostEvent::Left(mut guest) => {
|
||||
@@ -1266,6 +1614,13 @@ impl Game<'_> {
|
||||
"INFO",
|
||||
&format!("System chat: {} left the game", guest.name),
|
||||
);
|
||||
} else if self.player.is_some() && guest.placed {
|
||||
// ServerGamePacketListenerImpl.removePlayerFromWorld.
|
||||
let who = self.player_text(&guest.name, guest.uuid);
|
||||
self.system_message(
|
||||
Text::translate("multiplayer.player.left", &[who])
|
||||
.with_color(horizon_client::text::YELLOW),
|
||||
);
|
||||
}
|
||||
}
|
||||
HostEvent::Packet(conn, packet) => {
|
||||
@@ -1305,6 +1660,10 @@ impl Game<'_> {
|
||||
self.lan = Some(host);
|
||||
// A mace's wind burst from a guest's attack.
|
||||
let _ = self.flush_wind_bursts();
|
||||
// A sweep attack's reach to the other players from a guest's attack.
|
||||
self.flush_sweeps();
|
||||
// A reeled-in hook pulling a player.
|
||||
self.flush_hook_pulls();
|
||||
}
|
||||
|
||||
fn lan_guest_joined(
|
||||
@@ -1405,9 +1764,11 @@ impl Game<'_> {
|
||||
match e {
|
||||
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
// particles, bone meal's growth, anvil result sounds, or a
|
||||
// finished brushing (these levelEvent calls name no
|
||||
// excluded player), nor the digger's own crack and hit
|
||||
// sound (2019, 2020).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020 | 3008) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
@@ -1441,6 +1802,11 @@ impl Game<'_> {
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_entity_events.push((id, event, at));
|
||||
}
|
||||
// Event 21: a window that simulates the world itself is its own
|
||||
// client, and hears the guardian's hum too.
|
||||
if event == 21 && self.options.execution_role.is_client() {
|
||||
self.start_guardian_sound(id as u32);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
|
||||
/// emitter, and an animation for the attacker's client and observers once
|
||||
@@ -1469,6 +1835,7 @@ impl Game<'_> {
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
fn lan_flush_effects(&mut self, host: &mut LanHost) {
|
||||
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
|
||||
let kinetic_hits = std::mem::take(&mut self.lan_kinetic_hits);
|
||||
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
|
||||
if kind == 2001 {
|
||||
host.broken
|
||||
@@ -1476,6 +1843,16 @@ impl Game<'_> {
|
||||
}
|
||||
host.hub.level_event(kind, pos, data, acting);
|
||||
}
|
||||
// Level events 1010 and 1011: the guests' parrots dance or stop.
|
||||
for (pos, song) in std::mem::take(&mut self.lan_songs) {
|
||||
match song {
|
||||
Some(name) => {
|
||||
let id = host.hub.registry_id("minecraft:jukebox_song", &name);
|
||||
host.hub.level_event(1010, pos, id.unwrap_or(0), None);
|
||||
}
|
||||
None => host.hub.level_event(1011, pos, 0, None),
|
||||
}
|
||||
}
|
||||
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
|
||||
host.hub.block_event(pos, b0, b1, state);
|
||||
}
|
||||
@@ -1520,7 +1897,12 @@ impl Game<'_> {
|
||||
// A projectile is the direct entity of its hit: not tracked here.
|
||||
let melee = matches!(
|
||||
source.kind,
|
||||
K::PlayerAttack | K::MaceSmash | K::MobAttack | K::Sting | K::SonicBoom
|
||||
K::PlayerAttack
|
||||
| K::MaceSmash
|
||||
| K::Spear
|
||||
| K::MobAttack
|
||||
| K::Sting
|
||||
| K::SonicBoom
|
||||
);
|
||||
let direct = cause.filter(|_| melee);
|
||||
host.hub.damage_event(m.id as i32, kind, cause, direct, at);
|
||||
@@ -1541,6 +1923,19 @@ impl Game<'_> {
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
|
||||
for attacker in kinetic_hits {
|
||||
let hit = match attacker {
|
||||
None => self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| (host.host_entity, [p.position.x, p.position.y, p.position.z])),
|
||||
Some(conn) => host.hub.guest(conn).map(|g| (g.entity_id, g.position)),
|
||||
};
|
||||
if let Some((id, at)) = hit {
|
||||
host.hub.entity_event(id, 2, at);
|
||||
}
|
||||
}
|
||||
for (attacker, target, magic) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
@@ -1630,13 +2025,45 @@ impl Game<'_> {
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.crouching = g.sneaking;
|
||||
m.extra.avatar_pose = g.pose();
|
||||
m.extra.sprinting = g.sprinting;
|
||||
// Entity.DATA_POSE and the shared and living flags the others
|
||||
// get (hub::guest_body): gliding, swimming, Riptide's spin.
|
||||
let body = lan::hub::guest_body(g);
|
||||
m.extra.fall_flying = body.gliding;
|
||||
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
|
||||
m.extra.auto_spin_attack = body.spin_attack;
|
||||
m.velocity = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(Vec3::new(dx, dy, dz), |motion| motion.velocity);
|
||||
m.tick_avatar_pose(
|
||||
g.motion
|
||||
.as_ref()
|
||||
.is_some_and(|motion| motion.fluids.was_touching_water),
|
||||
);
|
||||
// CrossbowItem.isCharged: the held crossbow's projectiles.
|
||||
m.extra.charged = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
.and_then(|s| s.as_ref())
|
||||
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
|
||||
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
|
||||
m.extra.main_hand_left = g.info.main_hand == 0;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
m.extra.effect_ambient = visible().all(|e| e.ambient);
|
||||
// Entity.isInvisible: the invisibility effect hides the body (the
|
||||
// model pass, the name tag rules and `trader::is_invisible` read it).
|
||||
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
|
||||
if let Some(e) = g.effects.get("invisibility") {
|
||||
m.extra
|
||||
.effects
|
||||
.push(("minecraft:invisibility", e.duration, e.amplifier));
|
||||
}
|
||||
// Lying in its bed (LivingEntity.getBedOrientation).
|
||||
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
|
||||
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
|
||||
@@ -1647,6 +2074,18 @@ impl Game<'_> {
|
||||
let using = host.play.get(&g.conn).and_then(|p| p.using());
|
||||
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
|
||||
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
|
||||
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
|
||||
// item with `blocks_attacks`) raises the arm.
|
||||
m.extra.blocking = using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == OFFHAND_SLOT {
|
||||
m.extra.off_hand.as_ref()
|
||||
} else {
|
||||
m.extra.main_hand.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|i| self.res.items().get(i))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1697,8 +2136,22 @@ impl Game<'_> {
|
||||
),
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
m.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
if !m.glowing {
|
||||
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
|
||||
let guest = host.hub.guest(*conn);
|
||||
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
|
||||
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
|
||||
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! blocks, attacking and the commands all behave exactly as they do for the
|
||||
//! host, and every world change they cause reaches all players through the
|
||||
//! block log, the level events and the entity tracker.
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
|
||||
use super::{
|
||||
Dim, Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig,
|
||||
};
|
||||
use horizon_client::{
|
||||
command::vanilla,
|
||||
containers::BlockEntity,
|
||||
@@ -378,6 +380,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.level.player_extra, &mut g.saved);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
|
||||
@@ -610,6 +613,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.level.player_extra, &mut g.saved);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
self.adv.loaded = adv_loaded;
|
||||
@@ -926,6 +930,45 @@ impl Game<'_> {
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
|
||||
}
|
||||
|
||||
/// DedicatedServer.isUnderSpawnProtection: no operators, an operator or a
|
||||
/// radius of 0 or less is never under it; else a block within the radius
|
||||
/// of the world spawn (square, in the Overworld) is.
|
||||
fn under_spawn_protection(&self, host: &LanHost, gi: usize, pos: [i32; 3]) -> bool {
|
||||
let g = &host.hub.guests[gi];
|
||||
let radius = host.hub.spawn_protection;
|
||||
if self.options.dedicated.is_none() || radius <= 0 || g.dimension != "minecraft:overworld" {
|
||||
return false;
|
||||
}
|
||||
let Some(spawn) = self.level.world_spawn else {
|
||||
return false;
|
||||
};
|
||||
if host.hub.ops.is_empty() || host.hub.ops.contains_key(&g.uuid) {
|
||||
return false;
|
||||
}
|
||||
let dx = pos[0].abs_diff(spawn[0]);
|
||||
let dz = pos[2].abs_diff(spawn[2]);
|
||||
dx.max(dz) <= radius as u32
|
||||
}
|
||||
|
||||
/// ServerPlayer.sendSpawnProtectionMessage.
|
||||
fn spawn_protection_message(&self, host: &LanHost, conn: u32, pos: [i32; 3]) {
|
||||
let message = horizon_client::text::Text::translate(
|
||||
"build.spawn_protection",
|
||||
&[horizon_client::text::Text::literal(format!(
|
||||
"{}, {}, {}",
|
||||
pos[0], pos[1], pos[2]
|
||||
))],
|
||||
)
|
||||
.with_color(horizon_client::text::RED);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&message.component()),
|
||||
overlay: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// A packet from a guest that the hub does not handle itself.
|
||||
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -950,6 +993,12 @@ impl Game<'_> {
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: no dig starts
|
||||
// under spawn protection (the ack puts the block back).
|
||||
if self.under_spawn_protection(host, gi, a.pos) {
|
||||
self.spawn_protection_message(host, conn, a.pos);
|
||||
return;
|
||||
}
|
||||
let face = direction(i32::from(a.direction));
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = Some((a.pos, face));
|
||||
@@ -1063,19 +1112,25 @@ impl Game<'_> {
|
||||
],
|
||||
};
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
g.forced_hit = Some(hit);
|
||||
g.guest_use = Some(GuestUse::OnBlock(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
});
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
self.guest_use_sounds(host, conn, logged, heard);
|
||||
// handleUseItemOn: under spawn protection nothing is used or
|
||||
// placed; the player is told and the blocks below go back.
|
||||
if self.under_spawn_protection(host, gi, p.pos) {
|
||||
self.spawn_protection_message(host, conn, p.pos);
|
||||
} else {
|
||||
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
g.forced_hit = Some(hit);
|
||||
g.guest_use = Some(GuestUse::OnBlock(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
});
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
self.guest_use_sounds(host, conn, logged, heard);
|
||||
}
|
||||
// The client predicted a result: correct it with what really is.
|
||||
for pos in [
|
||||
p.pos,
|
||||
@@ -1097,14 +1152,25 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (p.yaw, p.pitch);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let used = self.as_guest(host, conn, |g| {
|
||||
// A horn's sound, inline instruments included.
|
||||
let horn = g.with_hand(hand, |g| {
|
||||
let held = g.held()?;
|
||||
g.res.items().instrument(held).map(|i| i.sound)
|
||||
});
|
||||
g.guest_use = Some(GuestUse::Item(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
(g.swings_started != swings, horn)
|
||||
});
|
||||
let (swung, horn) = match used {
|
||||
Some((swung, horn)) => (Some(swung), horn),
|
||||
None => (None, None),
|
||||
};
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
self.guest_instrument_sound(host, conn, heard, horn);
|
||||
}
|
||||
S::Attack(p) => self.guest_attack(host, conn, p.id),
|
||||
S::Interact(p) => {
|
||||
@@ -1200,6 +1266,7 @@ impl Game<'_> {
|
||||
}
|
||||
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
|
||||
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
|
||||
S::SetCommandBlock(p) => self.lan_set_command_block(host, conn, p),
|
||||
S::EditBook(p) => self.lan_edit_book(host, conn, p),
|
||||
S::ContainerSlotStateChanged(p) => {
|
||||
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
|
||||
@@ -1324,6 +1391,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.sync_rules();
|
||||
}
|
||||
// handleTeleportToEntityPacket: a spectator goes to the player,
|
||||
// in whichever level they are.
|
||||
S::TeleportToEntity(p) if spectator => self.guest_spectate_teleport(host, gi, p.uuid),
|
||||
S::ChangeGameMode(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if let Some(mode) = usize::try_from(p.mode)
|
||||
.ok()
|
||||
@@ -1374,19 +1444,68 @@ impl Game<'_> {
|
||||
.unwrap_or_default();
|
||||
host.hub.send_stats(conn, &stats);
|
||||
}
|
||||
// handlePickItemFromBlock: a block within the block reach plus 1, its
|
||||
// data only with infinite materials.
|
||||
S::PickItemFromBlock(p) => {
|
||||
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
|
||||
let item = self.res.facts().get(state).item.clone();
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.creative() && &*item != "minecraft:air" {
|
||||
let slot = g.selected;
|
||||
g.inventory[slot] = Some(ItemStack::new(&item, 1));
|
||||
}
|
||||
self.guest_pick_item(host, conn, |g| {
|
||||
let eye = g.eye_position()?;
|
||||
let d2: f64 = (0..3)
|
||||
.map(|a| {
|
||||
let lo = f64::from(p.pos[a]);
|
||||
(eye[a] - eye[a].clamp(lo, lo + 1.0)).powi(2)
|
||||
})
|
||||
.sum();
|
||||
let range = g.reach() + 1.0;
|
||||
if d2 >= range * range {
|
||||
return None;
|
||||
}
|
||||
g.picked_stack(p.pos, g.creative && p.include_data)
|
||||
});
|
||||
}
|
||||
// handlePickItemFromEntity.
|
||||
S::PickItemFromEntity(p) => {
|
||||
self.guest_pick_item(host, conn, |g| {
|
||||
g.within_entity_range(p.id)
|
||||
.then(|| g.entity_pick_result(p.id))
|
||||
.flatten()
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.tryPickItem` for the guest: a matching stack
|
||||
/// is selected, else a creative player gets the item; the held slot is sent
|
||||
/// back and the changed slots follow with the tick's inventory changes.
|
||||
fn guest_pick_item(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
stack: impl FnOnce(&mut Self) -> Option<ItemStack>,
|
||||
) {
|
||||
let picked = self.as_guest(host, conn, |g| {
|
||||
let stack = stack(g)?;
|
||||
let infinite = g.creative;
|
||||
horizon_client::pick_item::pick(
|
||||
&mut g.inventory[..36],
|
||||
&mut g.selected,
|
||||
&stack,
|
||||
infinite,
|
||||
);
|
||||
Some(())
|
||||
});
|
||||
if picked.flatten().is_some()
|
||||
&& let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn)
|
||||
{
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SetHeldSlot(play::SetHeldSlot {
|
||||
slot: g.selected as i32,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn item_from_network(
|
||||
&self,
|
||||
host: &LanHost,
|
||||
@@ -1402,6 +1521,51 @@ impl Game<'_> {
|
||||
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
|
||||
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
|
||||
/// sooner removes it too.
|
||||
/// `ServerGamePacketListenerImpl.handleTeleportToEntityPacket` for a
|
||||
/// player: the host's own, or a guest in the world.
|
||||
fn guest_spectate_teleport(&mut self, host: &mut LanHost, gi: usize, uuid: u128) {
|
||||
let own = host.hub.guests[gi].conn;
|
||||
let host_player = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| self.options.dedicated.is_none() && uuid == host.host_uuid)
|
||||
.map(|p| {
|
||||
(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim,
|
||||
)
|
||||
});
|
||||
let target = host_player.or_else(|| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
|
||||
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)))
|
||||
});
|
||||
let Some((at, yaw, pitch, dim)) = target else {
|
||||
return;
|
||||
};
|
||||
if Dim::from_id(&host.hub.guests[gi].dimension) != dim {
|
||||
// Only a dedicated server keeps its players in different levels.
|
||||
if self.options.dedicated.is_some() {
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
conn: own,
|
||||
dim,
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (yaw, pitch);
|
||||
// A new body there: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
host.hub.teleport(gi, Some(at));
|
||||
}
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.death_time += 1;
|
||||
@@ -1859,6 +2023,10 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
} else if in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
|
||||
// TeleportTransition.createDefault: no point of its own, so the
|
||||
// world spawn's free column once its chunks have loaded.
|
||||
self.world_spawn_trip(conn);
|
||||
}
|
||||
if let Some(c) = in_frame_bed
|
||||
&& self.dim == super::Dim::Overworld
|
||||
@@ -1877,6 +2045,10 @@ impl Game<'_> {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
if found.is_none() {
|
||||
// TeleportTransition.missingRespawnBlock.
|
||||
self.world_spawn_trip(conn);
|
||||
}
|
||||
host.hub.teleport(i, Some(at));
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), back) {
|
||||
@@ -1899,6 +2071,10 @@ impl Game<'_> {
|
||||
.filter(|g| host.hub.here(g) && g.loaded)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
// The players the guests' hooks can catch.
|
||||
if host.play.values().any(|p| p.hook.is_some()) {
|
||||
self.refresh_hook_targets(host);
|
||||
}
|
||||
for conn in conns {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
continue;
|
||||
@@ -1935,7 +2111,13 @@ impl Game<'_> {
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
if digging.is_some() || using || delayed {
|
||||
// ServerPlayer.getKnownMovement: the move their client just sent.
|
||||
let movement = host.hub.guests[gi]
|
||||
.moves
|
||||
.last()
|
||||
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.known_movement = movement;
|
||||
g.attack_ticks = g.attack_ticks.saturating_add(1);
|
||||
if let Some((pos, face)) = digging {
|
||||
g.forced_hit = Some(Hit {
|
||||
@@ -1989,6 +2171,7 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.tick_fishing();
|
||||
g.tick_maps();
|
||||
g.tick_lodestone_compasses();
|
||||
g.count_tick_stats(sleeping);
|
||||
g.tick_advancements();
|
||||
g.update_player_attributes();
|
||||
@@ -2015,7 +2198,7 @@ impl Game<'_> {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
g.last_death = Some(g.position.map(|v| v.floor() as i32));
|
||||
g.last_death = Some((g.dimension.clone(), g.position.map(|v| v.floor() as i32)));
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let reward = Experience {
|
||||
level: g.xp_level,
|
||||
@@ -2041,14 +2224,25 @@ impl Game<'_> {
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
let board = &self.score.board;
|
||||
host.hub.system_message_to(message.clone(), |other| {
|
||||
horizon_client::scoreboard::death_message_reaches(board, &name, other)
|
||||
});
|
||||
if self.options.dedicated.is_some() {
|
||||
// Only a message for everyone is logged (`broadcastSystemMessage`).
|
||||
if board.players_team(&name).is_none_or(|t| {
|
||||
t.death_message_visibility == horizon_client::scoreboard::Visibility::Always
|
||||
}) {
|
||||
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
} else if self.player.is_some()
|
||||
&& horizon_client::scoreboard::death_message_reaches(
|
||||
board,
|
||||
&name,
|
||||
super::chat::PLAYER_NAME,
|
||||
)
|
||||
{
|
||||
self.system_message(text);
|
||||
}
|
||||
message
|
||||
} else {
|
||||
@@ -2096,11 +2290,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SleepStatus.areEnoughSleeping` and `areEnoughDeepSleeping` over the
|
||||
/// host's player and the guests: of the players not in spectator mode,
|
||||
/// players_sleeping_percentage of them (at least one) are in bed, and as
|
||||
/// many have slept 100 ticks.
|
||||
pub(super) fn enough_slept(&self) -> bool {
|
||||
/// `SleepStatus.update`'s counts over the host's player and the guests:
|
||||
/// the players not in spectator mode, how many are in bed, and how many
|
||||
/// of those have slept 100 ticks.
|
||||
fn sleep_counts(&self) -> (i64, i64, i64) {
|
||||
let (mut active, mut sleeping, mut deep) = (0i64, 0i64, 0i64);
|
||||
let mut count = |in_bed: bool, timer: u32| {
|
||||
active += 1;
|
||||
@@ -2124,11 +2317,64 @@ impl Game<'_> {
|
||||
count(g.bed.is_some(), g.sleep_timer);
|
||||
}
|
||||
}
|
||||
(active, sleeping, deep)
|
||||
}
|
||||
|
||||
/// `SleepStatus.sleepersNeeded`.
|
||||
fn sleepers_needed(&self, active: i64) -> i64 {
|
||||
let percent = i64::from(self.ui.rules.int("players_sleeping_percentage").max(0));
|
||||
let needed = ((active * percent + 99) / 100).max(1);
|
||||
((active * percent + 99) / 100).max(1)
|
||||
}
|
||||
|
||||
/// `SleepStatus.areEnoughSleeping` and `areEnoughDeepSleeping` over the
|
||||
/// host's player and the guests: of the players not in spectator mode,
|
||||
/// players_sleeping_percentage of them (at least one) are in bed, and as
|
||||
/// many have slept 100 ticks.
|
||||
pub(super) fn enough_slept(&self) -> bool {
|
||||
let (active, sleeping, deep) = self.sleep_counts();
|
||||
let needed = self.sleepers_needed(active);
|
||||
sleeping >= needed && deep >= needed
|
||||
}
|
||||
|
||||
/// `ServerLevel.updateSleepingPlayerList` and `announceSleepStatus`: when
|
||||
/// the players or the sleepers change, a published or dedicated world
|
||||
/// shows everyone "n/m players sleeping", or "Sleeping through this
|
||||
/// night" once enough are in bed, on the action bar.
|
||||
pub(super) fn announce_sleep_status(&mut self) {
|
||||
let (active, sleeping, _) = self.sleep_counts();
|
||||
let (old_active, old_sleeping) = self.sleep_seen;
|
||||
self.sleep_seen = (active, sleeping);
|
||||
let changed = (old_sleeping > 0 || sleeping > 0)
|
||||
&& (old_active != active || old_sleeping != sleeping);
|
||||
let published = self.lan.is_some() || self.options.dedicated.is_some();
|
||||
if !changed || !published {
|
||||
return;
|
||||
}
|
||||
// ServerLevel.canSleepThroughNights.
|
||||
if self.ui.rules.int("players_sleeping_percentage") > 100 {
|
||||
return;
|
||||
}
|
||||
let needed = self.sleepers_needed(active);
|
||||
let text = if sleeping >= needed {
|
||||
horizon_client::text::Text::translate("sleep.skipping_night", &[])
|
||||
} else {
|
||||
horizon_client::text::Text::translate(
|
||||
"sleep.players_sleeping",
|
||||
&[
|
||||
horizon_client::text::Text::literal(sleeping.to_string()),
|
||||
horizon_client::text::Text::literal(needed.to_string()),
|
||||
],
|
||||
)
|
||||
};
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub
|
||||
.overlay_message(horizon_client::lan::data::component_nbt(&text.component()));
|
||||
}
|
||||
if self.options.dedicated.is_none() {
|
||||
self.titles.set_overlay(text, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.wakeUpAllPlayers` for the guests.
|
||||
pub(super) fn wake_guests(&mut self) {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
@@ -2184,16 +2430,18 @@ impl Game<'_> {
|
||||
if !g.effects.list.is_empty() {
|
||||
self.as_guest(host, conn, |game| game.tick_mob_effects());
|
||||
}
|
||||
self.as_guest(host, conn, |game| game.handle_shoulder_entities());
|
||||
return;
|
||||
}
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
|
||||
let mut soul = None;
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let freeze_immune = game.wears_freeze_immune();
|
||||
let burn_immune = game.wears_burn_from_stepping_immunity();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
@@ -2257,7 +2505,9 @@ impl Game<'_> {
|
||||
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
|
||||
// lit campfires and grown sweet berry bushes hurt (#1305).
|
||||
w.player.shift_key_down = sneaking;
|
||||
let _ = w.player.set_sleeping(sleeping);
|
||||
w.player.freeze_immune = freeze_immune;
|
||||
w.player.burn_from_stepping_immune = burn_immune;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.update_soul_speed_block();
|
||||
let _ = w.tick_environment_damage();
|
||||
@@ -2273,6 +2523,8 @@ impl Game<'_> {
|
||||
game.cauldron_extinguish(*pos);
|
||||
}
|
||||
}
|
||||
// Entity.baseTick: rain puts out a burning player.
|
||||
game.rain_extinguish_player();
|
||||
// WitherRoseBlock.entityInside along those moves.
|
||||
for event in &events {
|
||||
if matches!(event, MotionEvent::InsideWitherRose) {
|
||||
@@ -2284,12 +2536,16 @@ impl Game<'_> {
|
||||
for d in travelled {
|
||||
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
|
||||
}
|
||||
// LivingEntity.baseTick: a new block position runs the worn
|
||||
// enchantments' location_changed effects (frost walker).
|
||||
game.location_changed_effects([start.x, start.y, start.z], from_ground);
|
||||
// LivingEntity.baseTick: outside the world border, then tickEffects.
|
||||
game.border_damage();
|
||||
game.tick_mob_effects();
|
||||
// Soul Speed's effects (LivingEntity.baseTick's location_changed
|
||||
// and tick effects); its particle goes to the players around.
|
||||
soul = game.tick_soul_speed();
|
||||
game.handle_shoulder_entities();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
@@ -2321,7 +2577,10 @@ impl Game<'_> {
|
||||
if let Some((at, velocity)) = soul {
|
||||
super::lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.sculk_step_on_player();
|
||||
game.tick_player_sculk_events();
|
||||
});
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
@@ -2333,6 +2592,15 @@ impl Game<'_> {
|
||||
speed,
|
||||
}) => self.landing_dust(Some(host), state, position, count, speed),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
// LivingEntity.baseTick's entity event 67, to every client
|
||||
// around, the guest's own included; this window draws it too.
|
||||
MotionEvent::Drown => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
host.hub
|
||||
.entity_event(g.entity_id, super::combat::DROWN_EVENT, at);
|
||||
}
|
||||
self.drown_particles(at, [0.0; 3]);
|
||||
}
|
||||
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
|
||||
// others (Player.playSound excludes the player, whose client
|
||||
// plays it), the entity event 53 everyone around.
|
||||
@@ -2616,9 +2884,15 @@ impl Game<'_> {
|
||||
|
||||
/// Dropped items touching a guest go into their inventory.
|
||||
fn guest_pickups(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let (conn, mode, position, id) = {
|
||||
let (conn, mode, position, id, uuid) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
(g.conn, g.game_mode, g.position, g.entity_id)
|
||||
(
|
||||
g.conn,
|
||||
g.game_mode,
|
||||
g.position,
|
||||
g.entity_id,
|
||||
horizon_client::lan::playerdata::uuid_ints(g.uuid),
|
||||
)
|
||||
};
|
||||
if mode == SPECTATOR || self.dropped.is_empty() {
|
||||
return;
|
||||
@@ -2629,6 +2903,7 @@ impl Game<'_> {
|
||||
.enumerate()
|
||||
.filter(|(_, d)| {
|
||||
d.pickup_delay == 0
|
||||
&& d.target.is_none_or(|t| t == uuid)
|
||||
&& (d.position.x - position[0]).abs() < 1.5
|
||||
&& (d.position.z - position[2]).abs() < 1.5
|
||||
&& d.position.y > position[1] - 0.6
|
||||
@@ -2668,8 +2943,7 @@ impl Game<'_> {
|
||||
/// with Mending and gives the rest as points. Everyone near hears it.
|
||||
fn guest_orbs(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &host.hub.guests[gi];
|
||||
// Player's standing and crouching boxes: 1.8 and 1.5 high.
|
||||
let height = if g.sneaking { 1.5 } else { 1.8 };
|
||||
let height = f64::from(g.dimensions().height);
|
||||
let (conn, id, at) = (g.conn, g.entity_id, g.position);
|
||||
let unable = g.spectator() || g.dead || g.health <= 0.0;
|
||||
let play = host.play.entry(conn).or_default();
|
||||
@@ -2811,6 +3085,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear
|
||||
/// a guest's horn now, the guest's own client played it as it used it.
|
||||
fn guest_instrument_sound(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
heard: usize,
|
||||
horn: Option<String>,
|
||||
) {
|
||||
let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let mut i = heard.min(capture.len());
|
||||
while i < capture.len() {
|
||||
if capture[i].0 == horn {
|
||||
let (name, at, volume, pitch) = capture.remove(i);
|
||||
let source = horizon_client::settings::sound_source(&name) as i32;
|
||||
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Positional sounds and level events of the tick, for the guests.
|
||||
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
|
||||
let sounds = self.audio.capture.take().unwrap_or_default();
|
||||
@@ -2872,7 +3170,8 @@ impl Game<'_> {
|
||||
let packet = swing_packet(id, c.hand, c.animation);
|
||||
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
|
||||
if let Some(m) = host.models.get_mut(&conn) {
|
||||
m.swing();
|
||||
// The duration already counts the guest's haste or fatigue.
|
||||
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
|
||||
}
|
||||
}
|
||||
play.swing.tick();
|
||||
|
||||
@@ -24,6 +24,37 @@ fn slot_in_group(group: &str, slot: &str) -> bool {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `getAttributeValue(KNOCKBACK_RESISTANCE)` of a mob: its own value plus
|
||||
/// the modifiers of the items it wears or holds in matching slots.
|
||||
pub(super) fn mob_knockback_resistance(&self, m: &Mob) -> f64 {
|
||||
let e = &m.extra;
|
||||
let slots = [
|
||||
("mainhand", &e.main_hand),
|
||||
("offhand", &e.off_hand),
|
||||
("feet", &e.feet),
|
||||
("legs", &e.legs),
|
||||
("chest", &e.chest),
|
||||
("head", &e.head),
|
||||
];
|
||||
let mut extra = Vec::new();
|
||||
for (slot, item) in slots {
|
||||
let Some(info) = item.as_ref().and_then(|i| self.res.items().get(i)) else {
|
||||
continue;
|
||||
};
|
||||
for (attribute, _, amount, op, group) in &info.modifiers {
|
||||
if attribute == "minecraft:knockback_resistance" && slot_in_group(group, slot) {
|
||||
let op = match operation_named(op) {
|
||||
1 => Op::AddMultipliedBase,
|
||||
2 => Op::AddMultipliedTotal,
|
||||
_ => Op::AddValue,
|
||||
};
|
||||
extra.push((*amount, op));
|
||||
}
|
||||
}
|
||||
}
|
||||
finalize::value_with(m, Attr::KnockbackResistance, &extra)
|
||||
}
|
||||
|
||||
/// The registry id of a client-syncable attribute the entity type has
|
||||
/// (`AttributeMap.getInstance` is null for the others), by name.
|
||||
fn synced_attribute(&self, entity: &str, name: &str) -> Option<i32> {
|
||||
|
||||
@@ -11,18 +11,6 @@ use horizon_client::lan::{
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `ArmorStand`'s default poses (head, body, left arm, right arm, left leg,
|
||||
/// right leg), DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE (entries 16 to 21),
|
||||
/// under their `Pose` keys.
|
||||
const STAND_POSES: [(&str, [f32; 3]); 6] = [
|
||||
("Head", [0.0, 0.0, 0.0]),
|
||||
("Body", [0.0, 0.0, 0.0]),
|
||||
("LeftArm", [-10.0, 0.0, -10.0]),
|
||||
("RightArm", [-15.0, 0.0, 10.0]),
|
||||
("LeftLeg", [-1.0, 0.0, -1.0]),
|
||||
("RightLeg", [1.0, 0.0, 1.0]),
|
||||
];
|
||||
|
||||
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
|
||||
EntityState {
|
||||
id: id as i32,
|
||||
@@ -61,28 +49,21 @@ fn vehicle_meta(hurt_time: i32, hurt_dir: i32, damage: f32) -> Vec<(u8, i32, Val
|
||||
meta
|
||||
}
|
||||
|
||||
/// An armor stand's pose from its compound (`ArmorStandPose`), each part
|
||||
/// that differs from the default.
|
||||
fn stand_poses(raw: Option<&Tag>) -> Vec<(u8, i32, Value)> {
|
||||
let Some(pose) = raw.and_then(|t| t.get("Pose")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
/// An armor stand's pose (`ArmorStandPose`), each part that differs from the
|
||||
/// default, as DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE (entries 16 to 21).
|
||||
fn stand_poses(a: &horizon_client::entities::ArmorStand) -> Vec<(u8, i32, Value)> {
|
||||
let mut meta = Vec::new();
|
||||
for (i, (key, default)) in STAND_POSES.iter().enumerate() {
|
||||
let Some(Tag::List(values)) = pose.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let mut r = *default;
|
||||
for (k, v) in values.iter().take(3).enumerate() {
|
||||
if let Tag::Float(bits) = v {
|
||||
r[k] = f32::from_bits(*bits);
|
||||
}
|
||||
}
|
||||
if r != *default {
|
||||
for (i, (rotation, default)) in a
|
||||
.pose
|
||||
.iter()
|
||||
.zip(horizon_client::entities::ArmorStand::DEFAULT_POSE)
|
||||
.enumerate()
|
||||
{
|
||||
if *rotation != default {
|
||||
meta.push((
|
||||
16 + i as u8,
|
||||
serializer::ROTATIONS,
|
||||
Value::Floats(r.to_vec()),
|
||||
Value::Floats(rotation.to_vec()),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -185,7 +166,19 @@ impl Game<'_> {
|
||||
if flags != 0 {
|
||||
e.meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
e.meta.extend(stand_poses(self.persist.raw.get(&a.id)));
|
||||
e.meta.extend(stand_poses(a));
|
||||
// A pose put back to its default (`/data`) is sent as that default.
|
||||
e.defaults.push((15, serializer::BYTE, Value::Byte(0)));
|
||||
for (i, default) in horizon_client::entities::ArmorStand::DEFAULT_POSE
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
e.defaults.push((
|
||||
16 + i as u8,
|
||||
serializer::ROTATIONS,
|
||||
Value::Floats(default.to_vec()),
|
||||
));
|
||||
}
|
||||
// Stored main hand, off hand, feet, legs, chest, head: the wire's slots 0 to 5.
|
||||
for (slot, item) in a.equipment.iter().enumerate() {
|
||||
if item.is_some() {
|
||||
@@ -403,6 +396,9 @@ impl Game<'_> {
|
||||
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
|
||||
e.pitch = t.pitch;
|
||||
e.velocity = at(t.velocity);
|
||||
// Projectile.getAddEntityPacket: the owner's id; a trident here is
|
||||
// always the host player's.
|
||||
e.data = owner(Some(horizon_client::mobs::Who::Player));
|
||||
e.meta = arrow_meta(false, t.no_physics, 0, t.in_ground);
|
||||
// ThrownTrident.ID_LOYALTY and ID_FOIL.
|
||||
if t.loyalty != 0 {
|
||||
@@ -469,7 +465,9 @@ impl Game<'_> {
|
||||
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
|
||||
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
|
||||
// client draws them from these (`tick_remote_clouds`).
|
||||
let breath = self.network_particle(
|
||||
// The hub is out of `self.lan` meanwhile, so it is passed in.
|
||||
let breath = Self::hub_particle(
|
||||
hub,
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::DragonBreath,
|
||||
),
|
||||
@@ -527,11 +525,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn armor_stand_poses_send_only_what_differs() {
|
||||
let tag =
|
||||
horizon_world::nbt::parse_snbt("{Pose:{Head:[10f,20f,0f],LeftArm:[-10f,0f,-10f]}}")
|
||||
.unwrap()
|
||||
.0;
|
||||
let meta = stand_poses(Some(&tag));
|
||||
let mut stand = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
|
||||
stand.pose[0] = [10.0, 20.0, 0.0];
|
||||
let meta = stand_poses(&stand);
|
||||
assert_eq!(
|
||||
meta,
|
||||
vec![(
|
||||
@@ -540,6 +536,7 @@ mod tests {
|
||||
Value::Floats(vec![10.0, 20.0, 0.0])
|
||||
)]
|
||||
);
|
||||
assert!(stand_poses(None).is_empty());
|
||||
let plain = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
|
||||
assert!(stand_poses(&plain).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! (hits, pushes, fire, its projectiles) runs with that guest swapped in.
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
mobs::{Mob, MobEvent, PlayerView, Who},
|
||||
mobs::{Mob, MobEvent, PlayerView, Species, Who},
|
||||
projectiles::{Hit, Projectile},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
@@ -26,8 +26,38 @@ pub(super) fn player_for(
|
||||
guests: &GuestViews,
|
||||
bound: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
// A tame cat reads its owner wherever they are while they sleep, or just
|
||||
// woke up (`CatRelaxOnOwnerGoal` uses `getOwner`, not the nearest player).
|
||||
if m.species() == Species::Cat
|
||||
&& m.extra.tame
|
||||
&& let Some(owner) = m.extra.owner
|
||||
{
|
||||
let asleep = |v: &PlayerView| v.uuid == owner && (v.sleeping || v.sleep_timer > 0);
|
||||
if host.is_some_and(asleep) {
|
||||
return None;
|
||||
}
|
||||
if let Some((conn, _)) = guests.iter().find(|(_, v)| asleep(v)) {
|
||||
return Some(*conn);
|
||||
}
|
||||
}
|
||||
let targeting = m.ai.target == Some(Who::Player)
|
||||
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
|
||||
// A tame wolf, cat or parrot sees its owner (`TamableAnimal.getOwner`), while they
|
||||
// are in play, whoever is nearer. Not while it targets or was just hurt by
|
||||
// a player: then `Who::Player` is that attacker, whoever it is, and the
|
||||
// owner goals ignore it until the wolf lets go.
|
||||
if !targeting
|
||||
&& matches!(m.species(), Species::Wolf | Species::Cat | Species::Parrot)
|
||||
&& m.extra.tame
|
||||
&& let Some(owner) = m.extra.owner
|
||||
{
|
||||
if host.is_some_and(|v| v.alive && v.uuid == owner) {
|
||||
return None;
|
||||
}
|
||||
if let Some((conn, _)) = guests.iter().find(|(_, v)| v.alive && v.uuid == owner) {
|
||||
return Some(*conn);
|
||||
}
|
||||
}
|
||||
choose(m.position, targeting, host, guests, bound)
|
||||
}
|
||||
|
||||
@@ -65,11 +95,20 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
MobEvent::AttackPlayer(..)
|
||||
| MobEvent::AttackPlayerWithEffect { .. }
|
||||
| MobEvent::DamagePlayer { .. }
|
||||
| MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::Push {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::Knockback {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::Ignite {
|
||||
target: Who::Player,
|
||||
..
|
||||
@@ -80,6 +119,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
}
|
||||
| MobEvent::EffectPlayer { .. }
|
||||
| MobEvent::AxolotlSupportPlayer
|
||||
| MobEvent::ParrotToShoulder { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,9 +215,9 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.map(|g| {
|
||||
let [x, y, z] = g.position;
|
||||
let height = if g.sneaking { 1.5 } else { 1.8 };
|
||||
([x - 0.3, y, z - 0.3], [x + 0.3, y + height, z + 0.3])
|
||||
let b = g.bounds();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -319,7 +359,7 @@ impl Game<'_> {
|
||||
uuid: horizon_client::lan::playerdata::uuid_ints(g.uuid),
|
||||
position: g.position,
|
||||
eye: [x, y + g.eye_height(), z],
|
||||
height: if g.sneaking { 1.5 } else { 1.8 },
|
||||
height: g.dimensions().height,
|
||||
creative: g.creative(),
|
||||
spectator: g.spectator(),
|
||||
alive: !g.dead,
|
||||
@@ -336,14 +376,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest's bounding box (0.6 wide, 1.8 tall, 1.5 crouching).
|
||||
/// A guest's bounding box, by its pose (`Avatar.POSES`).
|
||||
pub(super) fn guest_bounds(g: &horizon_client::lan::guest::Guest) -> Aabb {
|
||||
let [x, y, z] = g.position;
|
||||
let height = if g.sneaking { 1.5 } else { 1.8 };
|
||||
Aabb::new(
|
||||
Vec3::new(x - 0.3, y, z - 0.3),
|
||||
Vec3::new(x + 0.3, y + height, z + 0.3),
|
||||
)
|
||||
g.bounds()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -358,6 +393,7 @@ mod tests {
|
||||
alive: true,
|
||||
creative,
|
||||
sneaking: false,
|
||||
swimming: false,
|
||||
look: [0.0; 3],
|
||||
main_hand: None,
|
||||
off_hand: None,
|
||||
@@ -369,11 +405,15 @@ mod tests {
|
||||
last_hurt: None,
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
sprinting: false,
|
||||
riding: false,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: [0; 4],
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,40 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
|
||||
/// player within 32 blocks. The window draws its own copy.
|
||||
pub(super) fn lan_level_particles(
|
||||
&self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
|
||||
/// landing block's crumbs: drawn in this window, when it has one, and
|
||||
/// sent to each player within 32 blocks. `host` is the hub of a guest's
|
||||
@@ -156,7 +190,7 @@ impl Game<'_> {
|
||||
|
||||
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
|
||||
/// guest's turn).
|
||||
fn hub_particle(
|
||||
pub(super) fn hub_particle(
|
||||
hub: &Hub,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
@@ -247,14 +281,10 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
|
||||
// ClientLevel.addParticle: the camera's 32 blocks unless the packet
|
||||
// (`force`) or the type overrides the limiter.
|
||||
// ClientLevel.addParticle: the camera's 32 blocks and the Particles
|
||||
// option, unless the packet or the type overrides the limiter.
|
||||
let add = |particles: &mut horizon_client::particles::ParticleEngine, at, v| {
|
||||
if p.override_limiter {
|
||||
particles.create(&options, at, v);
|
||||
} else {
|
||||
particles.add(&options, at, v);
|
||||
}
|
||||
particles.add_with(&options, at, v, p.override_limiter, p.always_show);
|
||||
};
|
||||
if p.count == 0 {
|
||||
add(
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
mobs::Who,
|
||||
projectiles::{Hit, Projectile, ProjectileKind},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// The player projectiles that meet other players: arrows (their hurt), and
|
||||
/// snowballs and eggs, which break on them (`Player.hurtServer` takes no 0
|
||||
@@ -24,7 +25,7 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
|
||||
}
|
||||
|
||||
/// Who a player's attack lands on.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Victim {
|
||||
Host,
|
||||
Guest(u32),
|
||||
@@ -40,6 +41,8 @@ pub(super) struct Strike {
|
||||
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
|
||||
magic: bool,
|
||||
from: [f64; 3],
|
||||
/// `getSecondsToDisableBlocking` of the attacker's weapon.
|
||||
disable_blocking: f32,
|
||||
sweep: Option<[f64; 3]>,
|
||||
/// `causeExtraKnockback`: its strength and direction (the attacker's
|
||||
/// yaw), and whether the hit is a sprinting knockback attack.
|
||||
@@ -47,6 +50,26 @@ pub(super) struct Strike {
|
||||
knockback_attack: bool,
|
||||
/// The attacker's team, for the victim's `Player.canHarmPlayer`.
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
/// The sweep's damage to the players around the victim.
|
||||
sweep_hit: Option<Sweep>,
|
||||
}
|
||||
|
||||
/// A sweep attack's reach to the other players (`Player.doSweepAttack`),
|
||||
/// worked out as the attacker.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Sweep {
|
||||
/// The attacking guest (none: the host).
|
||||
attacker: Option<u32>,
|
||||
from: [f64; 3],
|
||||
/// The damage after the enchantments and the attack strength.
|
||||
damage: f32,
|
||||
/// The push along the attacker's yaw.
|
||||
knockback: (f64, f64),
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
/// The target's box grown by (1, 0.25, 1).
|
||||
pub(super) hit_box: Aabb,
|
||||
/// The player the sweep started from, which it spares.
|
||||
skip: Option<Victim>,
|
||||
}
|
||||
|
||||
impl Strike {
|
||||
@@ -113,6 +136,10 @@ impl Game<'_> {
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let sweep_hit = sweep.map(|_| {
|
||||
let base = self.sweep_base_damage(scale);
|
||||
self.player_sweep(base, scale)
|
||||
});
|
||||
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
self.attack_ticks = 0;
|
||||
@@ -122,6 +149,14 @@ impl Game<'_> {
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
self.swing_arm();
|
||||
// LivingEntity.getSecondsToDisableBlocking: the held weapon's value,
|
||||
// none while another item is in use.
|
||||
let disable_blocking = if self.using.is_some() {
|
||||
0.0
|
||||
} else {
|
||||
self.attack_item_info()
|
||||
.map_or(0.0, |i| i.disable_blocking_seconds)
|
||||
};
|
||||
Some(Strike {
|
||||
attacker: self.lan_acting,
|
||||
amount,
|
||||
@@ -129,10 +164,12 @@ impl Game<'_> {
|
||||
crit,
|
||||
magic,
|
||||
from,
|
||||
disable_blocking,
|
||||
sweep,
|
||||
knockback: (strength, xd, zd),
|
||||
knockback_attack,
|
||||
team: self.player_team().cloned(),
|
||||
sweep_hit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,7 +280,9 @@ impl Game<'_> {
|
||||
// Player.attack: the hurt (with its knockback away from the
|
||||
// attacker), then causeExtraKnockback along the attacker's yaw.
|
||||
let land = |g: &mut Self| {
|
||||
g.combat.player_attacker = Some((s.from, s.disable_blocking));
|
||||
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
|
||||
g.combat.player_attacker = None;
|
||||
let (strength, xd, zd) = s.knockback;
|
||||
if hurt && strength > 0.0 {
|
||||
g.knock_back_player(f64::from(strength), xd, zd);
|
||||
@@ -300,9 +339,118 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(at) = s.sweep.filter(|_| hurt) {
|
||||
self.sweep_attack(at, Some(s.from));
|
||||
// The other players next to the victim (Player.doSweepAttack).
|
||||
let bounds = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| p.bounds()),
|
||||
Victim::Guest(conn) => host.hub.guest(conn).map(guest_bounds),
|
||||
};
|
||||
if let (Some(sweep), Some(bounds)) = (&s.sweep_hit, bounds) {
|
||||
self.sweep_players(
|
||||
host,
|
||||
&Sweep {
|
||||
hit_box: bounds.inflate(Vec3::new(1.0, 0.25, 1.0)),
|
||||
skip: Some(victim),
|
||||
..sweep.clone()
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.audio.play(sound, Some(s.from), 1.0, 1.0);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
|
||||
/// The sweep of the player in place, for the players it reaches: the
|
||||
/// damage (`1 + ratio * baseDamage`, plus the enchantments against a
|
||||
/// player, scaled by the attack strength) and who hits. The box is set
|
||||
/// by the caller.
|
||||
pub(super) fn player_sweep(&mut self, base: f32, scale: f32) -> Sweep {
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
|
||||
let damage = (base + self.enchant_damage_bonus("minecraft:player")) * scale;
|
||||
Sweep {
|
||||
attacker: self.lan_acting,
|
||||
from,
|
||||
damage,
|
||||
knockback: self.extra_knockback_direction(),
|
||||
team: self.player_team().cloned(),
|
||||
hit_box: Aabb::new(Vec3::ZERO, Vec3::ZERO),
|
||||
skip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep from a mob hit reaches the other players. Inside a guest's
|
||||
/// turn the host holds the other players (`as_guest`), so it waits for
|
||||
/// the turn to end.
|
||||
pub(super) fn sweep_other_players(&mut self, sweep: Sweep) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.creatures.sweeps.push(sweep);
|
||||
} else if let Some(mut host) = self.lan.take() {
|
||||
self.sweep_players(&mut host, &sweep);
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sweeps guests' turns put off.
|
||||
pub(super) fn flush_sweeps(&mut self) {
|
||||
for sweep in std::mem::take(&mut self.creatures.sweeps) {
|
||||
self.sweep_other_players(sweep);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.doSweepAttack` on the players: each other player in the box,
|
||||
/// closer than 3 blocks (`distanceToSqr < 9`) and not an ally
|
||||
/// (`isAlliedTo`), is hurt (`ServerPlayer.canHarmPlayer`: the `pvp` rule)
|
||||
/// and pushed 0.4 along the attacker's yaw.
|
||||
pub(super) fn sweep_players(&mut self, host: &mut LanHost, sweep: &Sweep) {
|
||||
let from = Vec3::new(sweep.from[0], sweep.from[1], sweep.from[2]);
|
||||
let mut victims = Vec::new();
|
||||
if sweep.attacker.is_some()
|
||||
&& self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& p.bounds().intersects(sweep.hit_box)
|
||||
&& p.position.distance_squared(from) < 9.0
|
||||
{
|
||||
victims.push((Victim::Host, self.player_team().cloned()));
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& sweep.attacker != Some(g.conn)
|
||||
&& guest_bounds(g).intersects(sweep.hit_box)
|
||||
&& Vec3::new(g.position[0], g.position[1], g.position[2]).distance_squared(from)
|
||||
< 9.0
|
||||
{
|
||||
let team = self.score.board.players_team(&g.name).cloned();
|
||||
victims.push((Victim::Guest(g.conn), team));
|
||||
}
|
||||
}
|
||||
let (xd, zd) = sweep.knockback;
|
||||
let land = |g: &mut Self| {
|
||||
let hurt = g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack");
|
||||
if hurt {
|
||||
g.knock_back_player(0.4, xd, zd);
|
||||
}
|
||||
};
|
||||
for (victim, team) in victims {
|
||||
if sweep.skip == Some(victim)
|
||||
|| horizon_client::scoreboard::teams_allied(team.as_ref(), sweep.team.as_ref())
|
||||
|| !self.ui.rules.bool("pvp")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => {
|
||||
self.guest_struck_by(host, conn, land);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,10 @@
|
||||
//! forcing the game mode. Its difficulty lock asks first (PopupScreen), and
|
||||
//! Restrictions opens RestrictionsScreen.
|
||||
//! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen
|
||||
use super::{
|
||||
Game, Screen,
|
||||
game_rules_screen::{Exit, RulesScreen},
|
||||
integrated::ServerOptions,
|
||||
lan::LanSettings,
|
||||
};
|
||||
use super::{Game, Screen, integrated::ServerOptions, lan::LanSettings};
|
||||
use horizon_client::{
|
||||
font::Font,
|
||||
game_rules_screen::{Exit, RulesScreen},
|
||||
hud::{HudBatch, rgba},
|
||||
text::{Text, named_color},
|
||||
ui::{self, EditBox},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Level events whose client handler (`LevelEventHandler`) spawns particles
|
||||
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005).
|
||||
//! consumable's teleport (2017), an enderman's (2018), electric sparks (3002),
|
||||
//! waxing (3003), wax off (3004) and scraping (3005).
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
@@ -10,13 +10,17 @@ use horizon_world::level::LevelEvent;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
|
||||
self.particles.block_teleportation(pos, data, [16, 8, 16]);
|
||||
}
|
||||
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
|
||||
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [127, 127, 127], self.settings.particles);
|
||||
.block_teleportation(pos, data, [127, 127, 127]);
|
||||
}
|
||||
/// Event 2018: an enderman's portal trail.
|
||||
pub(super) fn enderman_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.enderman_teleportation(pos, data, self.settings.particles);
|
||||
}
|
||||
/// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client
|
||||
/// level's random.
|
||||
@@ -135,6 +139,30 @@ impl Game<'_> {
|
||||
// Each guest plays it from the global packet.
|
||||
self.audio.play_unshared(sound, at, volume, 1.0);
|
||||
}
|
||||
/// `ClientLevel.addBreakingBlockEffects`: the block's hit sound (only for
|
||||
/// `play_sound`) and one crumb on the face being mined; nothing for air.
|
||||
pub(super) fn breaking_block_effects(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
play_sound: bool,
|
||||
) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if self.res.facts().is_air(state) {
|
||||
return;
|
||||
}
|
||||
if play_sound {
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Each guest plays it from the level event itself.
|
||||
self.audio.play_unshared(
|
||||
&sounds.hit,
|
||||
Some(pos.map(|v| f64::from(v) + 0.5)),
|
||||
(sounds.volume + 1.0) / 8.0,
|
||||
sounds.pitch * 0.5,
|
||||
);
|
||||
}
|
||||
self.crack_particle(pos, face, state);
|
||||
}
|
||||
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
|
||||
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
|
||||
/// spell particles in the potion's colour `data`, flung out in a disc.
|
||||
|
||||
@@ -46,8 +46,12 @@ impl Game<'_> {
|
||||
if !self.with_level(|b, level, _| b.is_raining_at(level, p)) {
|
||||
continue;
|
||||
}
|
||||
// Skeleton trap: effective difficulty / 100 (regional difficulty approximated).
|
||||
let difficulty = f64::from(self.ui.difficulty) * 0.75;
|
||||
// Skeleton trap: the local effective difficulty / 100
|
||||
// (`getCurrentDifficultyAt(pos)`).
|
||||
let difficulty = f64::from(
|
||||
self.local_difficulty([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])])
|
||||
.effective,
|
||||
);
|
||||
let below = &self
|
||||
.res
|
||||
.facts()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
enchant::EffectContext,
|
||||
items::ItemStack,
|
||||
mobs::{Damage, Mob, Species},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
@@ -37,6 +38,41 @@ pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|
||||
|| m.species() == Species::Allay && m.extra.tame
|
||||
}
|
||||
|
||||
/// A smash's knockback on the players, put off while a guest's turn holds
|
||||
/// the other players.
|
||||
pub(super) struct SmashPush {
|
||||
center: Vec3,
|
||||
heavy: f64,
|
||||
attacker: Option<u32>,
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
}
|
||||
/// `MaceItem.getKnockbackPower` and the push of one entity at `at`, `heavy`
|
||||
/// being 2 after a fall of more than five blocks.
|
||||
fn smash_push(center: Vec3, heavy: f64, at: Vec3, velocity: &mut Vec3, resistance: f64) {
|
||||
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
|
||||
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
|
||||
return;
|
||||
}
|
||||
let length = distance_sqr.sqrt();
|
||||
let power =
|
||||
(KNOCKBACK_RADIUS - length) * f64::from(KNOCKBACK_POWER) * heavy * (1.0 - resistance);
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
|
||||
let (x, z) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(d[0] / length * power, d[2] / length * power)
|
||||
};
|
||||
*velocity = Vec3::new(
|
||||
velocity.x + x,
|
||||
velocity.y + f64::from(KNOCKBACK_POWER),
|
||||
velocity.z + z,
|
||||
);
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn holds_mace(&self) -> bool {
|
||||
self.inventory[self.selected]
|
||||
@@ -100,7 +136,7 @@ impl Game<'_> {
|
||||
/// `LivingEntity.hurtServer` of the mob at `index` by the player's hit:
|
||||
/// the weapon's `armor_effectiveness` enchantments (Breach) thin its armor.
|
||||
pub(super) fn hurt_by_player(&mut self, index: usize, amount: f32, source: Damage) -> bool {
|
||||
let held = self.inventory[self.selected].clone();
|
||||
let held = self.held().cloned();
|
||||
let kind = self.mobs[index].kind.id.clone();
|
||||
let ctx = EffectContext {
|
||||
this: Some(&kind),
|
||||
@@ -109,6 +145,7 @@ impl Game<'_> {
|
||||
is_direct: true,
|
||||
damage_type: Some(match source.kind {
|
||||
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
|
||||
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
|
||||
_ => "minecraft:player_attack",
|
||||
}),
|
||||
items: Some(self.res.items()),
|
||||
@@ -138,7 +175,7 @@ impl Game<'_> {
|
||||
/// the post-attack enchantments, then `postHurtEnemy` with the weapon's
|
||||
/// wear.
|
||||
pub(super) fn weapon_hit(&mut self, index: usize) {
|
||||
let Some(stack) = self.inventory[self.selected].clone() else {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
let weapon = self
|
||||
@@ -158,16 +195,26 @@ impl Game<'_> {
|
||||
if smash && let Some(p) = self.player.as_mut() {
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
// ItemStack.postHurtEnemy: hurtAndBreak(weapon.itemDamagePerAttack).
|
||||
self.weapon_wear(&stack);
|
||||
}
|
||||
/// `ItemStack.postHurtEnemy`: `hurtAndBreak(weapon.itemDamagePerAttack)` of
|
||||
/// the weapon in the acting hand.
|
||||
pub(super) fn weapon_wear(&mut self, stack: &ItemStack) {
|
||||
let weapon = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
|
||||
if let Some((per_attack, max)) = weapon
|
||||
&& max > 0
|
||||
&& !self.creative
|
||||
{
|
||||
let loss = self.durability_loss(&stack, per_attack);
|
||||
if let Some(held) = self.inventory[self.selected].as_mut() {
|
||||
let loss = self.durability_loss(stack, per_attack);
|
||||
let cell = self.held_mut();
|
||||
if let Some(held) = cell.as_mut() {
|
||||
held.damage += loss;
|
||||
if held.damage >= max {
|
||||
self.inventory[self.selected] = None;
|
||||
*cell = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,44 +267,75 @@ impl Game<'_> {
|
||||
];
|
||||
self.level_event_at(2013, on_pos, 750);
|
||||
let heavy = if fall > HEAVY_THRESHOLD { 2.0 } else { 1.0 };
|
||||
// getKnockbackPower and the push of one entity at `at`.
|
||||
let push = |at: Vec3, velocity: &mut Vec3, resistance: f64| {
|
||||
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
|
||||
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
|
||||
return;
|
||||
}
|
||||
let length = distance_sqr.sqrt();
|
||||
let power = (KNOCKBACK_RADIUS - length)
|
||||
* f64::from(KNOCKBACK_POWER)
|
||||
* heavy
|
||||
* (1.0 - resistance);
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
|
||||
let (x, z) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(d[0] / length * power, d[2] / length * power)
|
||||
};
|
||||
*velocity = Vec3::new(
|
||||
velocity.x + x,
|
||||
velocity.y + f64::from(KNOCKBACK_POWER),
|
||||
velocity.z + z,
|
||||
);
|
||||
};
|
||||
let player = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.id != id && m.alive() && !m.removed && !ally_of_player(m, player))
|
||||
{
|
||||
push(m.position, &mut m.velocity, m.attrs.knockback_resistance);
|
||||
smash_push(
|
||||
center,
|
||||
heavy,
|
||||
m.position,
|
||||
&mut m.velocity,
|
||||
m.attrs.knockback_resistance,
|
||||
);
|
||||
}
|
||||
for s in self.armor_stands.iter_mut().filter(|s| !s.marker) {
|
||||
push(s.position, &mut s.velocity, 0.0);
|
||||
smash_push(center, heavy, s.position, &mut s.velocity, 0.0);
|
||||
}
|
||||
// The host and the LAN guests are living entities too. A guest's turn
|
||||
// holds the other players (`as_guest`): the push waits for it to end.
|
||||
let push = SmashPush {
|
||||
center,
|
||||
heavy,
|
||||
attacker: self.lan_acting,
|
||||
team: self.player_team().cloned(),
|
||||
};
|
||||
if self.lan_acting.is_some() {
|
||||
self.creatures.smash_pushes.push(push);
|
||||
} else {
|
||||
self.smash_push_players(&push);
|
||||
}
|
||||
}
|
||||
/// `MaceItem.knockback` on the players but the attacker: the host unless
|
||||
/// it attacked, and every guest but the attacker.
|
||||
fn smash_push_players(&mut self, push: &SmashPush) {
|
||||
if push.attacker.is_some() {
|
||||
self.smash_push_player(push);
|
||||
}
|
||||
self.each_guest_hit_except(push.attacker, |g| g.smash_push_player(push));
|
||||
}
|
||||
/// The pushes guests' smashes put off.
|
||||
pub(super) fn flush_smash_pushes(&mut self) {
|
||||
for push in std::mem::take(&mut self.creatures.smash_pushes) {
|
||||
self.smash_push_players(&push);
|
||||
}
|
||||
}
|
||||
/// `MaceItem.knockback` on the player here (the host, or the guest swapped
|
||||
/// in): not a spectator, an ally of the attacker (`isAlliedTo`: the same
|
||||
/// team) or a creative player flying.
|
||||
fn smash_push_player(&mut self, push: &SmashPush) {
|
||||
if self.spectator()
|
||||
|| horizon_client::scoreboard::teams_allied(push.team.as_ref(), self.player_team())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let creative = self.creative;
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if creative && p.flying {
|
||||
return;
|
||||
}
|
||||
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
|
||||
smash_push(
|
||||
push.center,
|
||||
push.heavy,
|
||||
p.position,
|
||||
&mut p.velocity,
|
||||
resistance,
|
||||
);
|
||||
}
|
||||
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
|
||||
/// the landing block's particles, 50 for every block fallen (200 at most).
|
||||
|
||||
@@ -41,6 +41,8 @@ enum Kind {
|
||||
Crafter,
|
||||
/// `BrewingStandBlockEntity` (a `WorldlyContainer`).
|
||||
Brewing,
|
||||
/// `ChiseledBookShelfBlockEntity`: books only, one to a slot.
|
||||
Bookshelf,
|
||||
/// `ComposterBlock.getContainer` at this level (a `WorldlyContainerHolder`
|
||||
/// without a block entity): input below 7, empty at 7, bone meal at 8.
|
||||
Composter(i32),
|
||||
@@ -82,7 +84,7 @@ impl View {
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_from_name(name: &str) -> Option<Direction> {
|
||||
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
|
||||
Some(match name {
|
||||
"down" => Direction::Down,
|
||||
"up" => Direction::Up,
|
||||
@@ -169,6 +171,7 @@ impl Containers<'_> {
|
||||
(BlockEntity::Furnace(_), _) => Kind::Furnace,
|
||||
(BlockEntity::Crafter(_), _) => Kind::Crafter,
|
||||
(BlockEntity::Brewing(_), _) => Kind::Brewing,
|
||||
(BlockEntity::ChiseledBookshelf(_), _) => Kind::Bookshelf,
|
||||
(_, "minecraft:hopper") => Kind::Hopper,
|
||||
_ => Kind::Generic,
|
||||
};
|
||||
@@ -250,6 +253,16 @@ impl Containers<'_> {
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
if view.kind == Kind::Bookshelf {
|
||||
// ListBackedContainer.canPlaceItem: `acceptsItemType` (the books)
|
||||
// and a free slot, as `getMaxStackSize` is 1.
|
||||
return self
|
||||
.items
|
||||
.item_tags
|
||||
.get("minecraft:bookshelf_books")
|
||||
.is_some_and(|m| m.contains(&*stack.item))
|
||||
&& self.get(view, slot).is_none();
|
||||
}
|
||||
if view.kind == Kind::Brewing {
|
||||
// BrewingStandBlockEntity.canPlaceItem
|
||||
return match slot {
|
||||
@@ -399,6 +412,45 @@ pub(super) fn container_signal(
|
||||
.map_or(0, |v| c.signal(&v))
|
||||
}
|
||||
|
||||
/// `ChiseledBookShelfBlock.getAnalogOutputSignal` and
|
||||
/// `ShelfBlock.getAnalogOutputSignal`, from the block entity Horizon keeps as
|
||||
/// it was loaded; `None` for any other block. `direction` points from the
|
||||
/// block to the comparator.
|
||||
pub(super) fn shelf_signal(
|
||||
world: &World,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
state: BlockStateId,
|
||||
pos: BlockPos,
|
||||
direction: Direction,
|
||||
) -> Option<i32> {
|
||||
let info = registry.state(state)?;
|
||||
let chiseled = info.name == "minecraft:chiseled_bookshelf";
|
||||
if !chiseled && !info.name.ends_with("_shelf") {
|
||||
return None;
|
||||
}
|
||||
if chiseled {
|
||||
// The last slot touched, plus one (`lastInteractedSlot` starts at -1).
|
||||
return Some(match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
Some(BlockEntity::ChiseledBookshelf(b)) => b.last_slot + 1,
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
|
||||
// Only read from the back: a bit for each of the three slots in use.
|
||||
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
|
||||
if facing.map(Direction::opposite) != Some(direction) {
|
||||
return Some(0);
|
||||
}
|
||||
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
|
||||
Some(
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, item)| i32::from(item.is_some()) << slot)
|
||||
.sum(),
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn containers(&self) -> Containers<'_> {
|
||||
Containers {
|
||||
@@ -433,6 +485,7 @@ impl Game<'_> {
|
||||
if pos.1 == super::minecarts::CART_KEY_Y {
|
||||
return;
|
||||
}
|
||||
self.chiseled_bookshelf_synced(pos);
|
||||
// A double chest's other half changes with it.
|
||||
let halves: Vec<Pos> = std::iter::once(pos)
|
||||
.chain(self.chest_other_half(pos))
|
||||
@@ -595,27 +648,41 @@ impl Game<'_> {
|
||||
self.container_changed(pos);
|
||||
}
|
||||
}
|
||||
/// `HopperBlockEntity.ejectItems`.
|
||||
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
|
||||
/// a container entity there (`getContainerAt`).
|
||||
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
|
||||
return false;
|
||||
};
|
||||
let Some(target) = c.view(offset(pos, facing), false) else {
|
||||
let front = offset(pos, facing);
|
||||
let face = facing.opposite();
|
||||
if let Some(target) = c.view(front, false) {
|
||||
return self.eject_into(pos, hopper, &target, face);
|
||||
}
|
||||
let Some(id) = self.entity_container_at([
|
||||
f64::from(front.0) + 0.5,
|
||||
f64::from(front.1) + 0.5,
|
||||
f64::from(front.2) + 0.5,
|
||||
]) else {
|
||||
return false;
|
||||
};
|
||||
let face = facing.opposite();
|
||||
if c.is_full(&target, face) {
|
||||
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
|
||||
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
|
||||
if self.containers().is_full(target, face) {
|
||||
return false;
|
||||
}
|
||||
for slot in 0..hopper.len() {
|
||||
let Some(one) = self.take_one(hopper, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(Some(pos), &target, one, Some(face)) {
|
||||
match self.add_item(Some(pos), target, one, Some(face)) {
|
||||
None => {
|
||||
self.view_changed(&target);
|
||||
self.view_changed(target);
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(hopper, slot, rest),
|
||||
@@ -626,46 +693,13 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
|
||||
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let above = (pos.0, pos.1 + 1, pos.2);
|
||||
let c = self.containers();
|
||||
if let Some(source) = c.view(above, false) {
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return false;
|
||||
}
|
||||
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
|
||||
// its setChanged, which empties the composter, and so does the
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
|
||||
return moved;
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(&source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(&source);
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(&source, slot, rest),
|
||||
}
|
||||
}
|
||||
return false;
|
||||
let at = [
|
||||
f64::from(pos.0) + 0.5,
|
||||
f64::from(pos.1) + 1.5,
|
||||
f64::from(pos.2) + 0.5,
|
||||
];
|
||||
if let Some(took) = self.suck_from_container(above, at, hopper) {
|
||||
return took;
|
||||
}
|
||||
let above_state = self.world.block(above.0, above.1, above.2);
|
||||
let blocked = self.res.facts().get(above_state).full_collision
|
||||
@@ -695,6 +729,168 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.suck_item_entities(hopper, min, max)
|
||||
}
|
||||
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
|
||||
/// container at `above`, through its bottom face; with no block container
|
||||
/// there, from a container entity whose box meets the one around `at`
|
||||
/// (`getSourceContainerOrHandler`). `None` when there is no container.
|
||||
fn suck_from_container(&mut self, above: Pos, at: [f64; 3], hopper: &View) -> Option<bool> {
|
||||
let c = self.containers();
|
||||
let Some(source) = c.view(above, false) else {
|
||||
let id = self.entity_container_at(at)?;
|
||||
return self
|
||||
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
|
||||
};
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return Some(false);
|
||||
}
|
||||
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
|
||||
// its setChanged, which empties the composter, and so does the
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
|
||||
return Some(moved);
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
Some(self.suck_from_slots(source_hopper, &source, hopper))
|
||||
}
|
||||
/// `HopperBlockEntity.tryTakeInItemFromSlot` over the `DOWN` face of `source`.
|
||||
fn suck_from_slots(
|
||||
&mut self,
|
||||
source_hopper: Option<Pos>,
|
||||
source: &View,
|
||||
hopper: &View,
|
||||
) -> bool {
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(source);
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(source, slot, rest),
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `HopperBlockEntity.getEntityContainer`: one of the alive container
|
||||
/// entities (`EntitySelector.CONTAINER_ENTITY_SELECTOR`: chest and hopper
|
||||
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
|
||||
/// picked at random.
|
||||
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
|
||||
let meets = |b: horizon_core::geometry::Aabb| {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
lo.x < at[0] + 0.5
|
||||
&& hi.x > at[0] - 0.5
|
||||
&& lo.y < at[1] + 0.5
|
||||
&& hi.y > at[1] - 0.5
|
||||
&& lo.z < at[2] + 0.5
|
||||
&& hi.z > at[2] - 0.5
|
||||
};
|
||||
let mut found: Vec<u32> = self
|
||||
.carts
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
matches!(
|
||||
c.kind,
|
||||
super::minecarts::CartKind::Chest | super::minecarts::CartKind::Hopper
|
||||
) && meets(c.bounds())
|
||||
})
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
found.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| b.chest && meets(b.bounds()))
|
||||
.map(|b| b.id),
|
||||
);
|
||||
if found.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(found.len() as i32)
|
||||
.unwrap_or(0);
|
||||
found.get(pick as usize).copied()
|
||||
}
|
||||
/// Runs `f` on a container entity's items as a container, under the
|
||||
/// placeholder key its menu uses (the open menu's copy when it is open),
|
||||
/// then hands the items back to the entity.
|
||||
fn with_entity_container<R>(
|
||||
&mut self,
|
||||
id: u32,
|
||||
f: impl FnOnce(&mut Self, &View) -> R,
|
||||
) -> Option<R> {
|
||||
let mut items = if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
std::mem::take(&mut c.items)
|
||||
} else {
|
||||
std::mem::take(&mut self.boats.iter_mut().find(|b| b.id == id)?.items)
|
||||
};
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, id as i32);
|
||||
let (view, open) = self.lend_items(key, &mut items);
|
||||
let result = f(self, &view);
|
||||
self.reclaim_items(key, open, &mut items);
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
c.items = items;
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.items = items;
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
/// A vehicle's slots as a container under its placeholder `key`. When its
|
||||
/// menu is open the placeholder is the live copy and stays as it is.
|
||||
/// Returns the view and whether the menu was open.
|
||||
fn lend_items(&mut self, key: Pos, items: &mut Vec<Option<ItemStack>>) -> (View, bool) {
|
||||
let open = self.world.block_entities.contains_key(&key);
|
||||
if !open {
|
||||
let slots = BlockEntity::Chest(std::mem::take(items));
|
||||
self.world.block_entities.insert(key, slots);
|
||||
}
|
||||
let size = self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.map_or(0, |e| e.slots().len());
|
||||
let view = View {
|
||||
parts: vec![(key, size)],
|
||||
kind: Kind::Generic,
|
||||
};
|
||||
(view, open)
|
||||
}
|
||||
/// Gives `lend_items`' slots back to the vehicle, so its own items stay
|
||||
/// the one source of truth: a menu copy that is still around (open, or
|
||||
/// left by a guest's close) gets the changed items copied to `items`.
|
||||
fn reclaim_items(&mut self, key: Pos, open: bool, items: &mut Vec<Option<ItemStack>>) {
|
||||
let slots = if open {
|
||||
self.world.block_entities.get(&key).cloned()
|
||||
} else {
|
||||
self.world.block_entities.remove(&key)
|
||||
};
|
||||
if let Some(BlockEntity::Chest(slots)) = slots {
|
||||
*items = slots;
|
||||
}
|
||||
}
|
||||
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
|
||||
/// adds the item entities that meet the box, until one is taken whole.
|
||||
fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool {
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let b = self.dropped[i].bounds();
|
||||
@@ -719,6 +915,44 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
|
||||
/// hopper minecart: one item from the container above, else the item
|
||||
/// entities over it or touching it, each tick without a cooldown.
|
||||
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
|
||||
// The cart's slots are edited as a container under the placeholder key
|
||||
// its menu uses; when that menu is open the placeholder is the live copy.
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32);
|
||||
let (view, open) = self.lend_items(key, &mut cart.items);
|
||||
let p = cart.position;
|
||||
// getLevelY is the cart's y + 0.5, so the container is the block at
|
||||
// y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16
|
||||
// above the cart.
|
||||
let above = (
|
||||
p.x.floor() as i32,
|
||||
(p.y + 1.5).floor() as i32,
|
||||
p.z.floor() as i32,
|
||||
);
|
||||
let at = [p.x, p.y + 1.5, p.z];
|
||||
let took = match self.suck_from_container(above, at, &view) {
|
||||
Some(took) => took,
|
||||
None => self.suck_item_entities(
|
||||
&view,
|
||||
[p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5],
|
||||
[p.x + 0.5, p.y + 2.0, p.z + 0.5],
|
||||
),
|
||||
};
|
||||
if !took {
|
||||
// The cart's own box, inflated by 0.25 sideways.
|
||||
let b = cart.bounds();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.suck_item_entities(
|
||||
&view,
|
||||
[lo.x - 0.25, lo.y, lo.z - 0.25],
|
||||
[hi.x + 0.25, hi.y, hi.z + 0.25],
|
||||
);
|
||||
}
|
||||
self.reclaim_items(key, open, &mut cart.items);
|
||||
}
|
||||
|
||||
// ---- dispensers and droppers ----
|
||||
|
||||
@@ -955,6 +1189,7 @@ mod item_column_tests {
|
||||
id,
|
||||
stack: ItemStack::new("minecraft:stone", 1),
|
||||
thrower: None,
|
||||
target: None,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
|
||||
@@ -324,6 +324,13 @@ const DECORATION_TYPES: [(&str, &str); 24] = [
|
||||
("mineshaft", "map/decorations/mineshaft"),
|
||||
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
|
||||
];
|
||||
/// `MapDecorationType.showOnItemFrame` is false for the player arrows and the two markers.
|
||||
fn shown_on_frame(sprite: &str) -> bool {
|
||||
!matches!(
|
||||
decoration_type(sprite),
|
||||
Some("player" | "red_marker" | "blue_marker" | "player_off_map" | "player_off_limits")
|
||||
)
|
||||
}
|
||||
/// The sprite of a decoration type that a `minecraft:map_decorations`
|
||||
/// entry or a server's packet names.
|
||||
fn decoration_sprite(id: &str) -> Option<&'static str> {
|
||||
@@ -714,6 +721,93 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `CompassItem.useOn`: a compass used on a lodestone locks onto it (a
|
||||
/// single one in survival changes in place, else one is split off).
|
||||
pub(super) fn use_lodestone_compass(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:compass" {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = hit.pos;
|
||||
if self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(x, y, z))
|
||||
.name
|
||||
.as_ref()
|
||||
!= "minecraft:lodestone"
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let at = [x, y, z].map(|v| f64::from(v) + 0.5);
|
||||
self.audio
|
||||
.play("minecraft:item.lodestone_compass.lock", Some(at), 1.0, 1.0);
|
||||
let target = Tag::compound([(
|
||||
"target",
|
||||
Tag::compound([
|
||||
("dimension", Tag::String(self.dimension_id().into())),
|
||||
("pos", Tag::IntArray(vec![x, y, z])),
|
||||
]),
|
||||
)]);
|
||||
if !self.creative && stack.count == 1 {
|
||||
if let Some(held) = self.held_mut() {
|
||||
held.data_mut()
|
||||
.set_other("minecraft:lodestone_tracker", Some(target));
|
||||
}
|
||||
} else {
|
||||
let mut locked = stack.clone();
|
||||
locked.count = 1;
|
||||
locked
|
||||
.data_mut()
|
||||
.set_other("minecraft:lodestone_tracker", Some(target));
|
||||
self.consume_held();
|
||||
self.give_or_drop_stack(locked);
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `CompassItem.inventoryTick` / `LodestoneTracker.tick`: a compass whose
|
||||
/// lodestone is gone loses its target. A target in an unloaded chunk waits.
|
||||
pub(super) fn tick_lodestone_compasses(&mut self) {
|
||||
let dim = self.dimension_id();
|
||||
let gone = |game: &Self, stack: &ItemStack| -> bool {
|
||||
let Some(tracker) = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:lodestone_tracker"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (Some(target), true) = (
|
||||
tracker.get("target"),
|
||||
horizon_client::save::number(tracker.get("tracked")).is_none_or(|v| v != 0),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Some(Tag::IntArray(pos)) = target.get("pos") else {
|
||||
return false;
|
||||
};
|
||||
let [x, y, z] = [pos[0], pos[1], pos[2]];
|
||||
horizon_client::save::text(target.get("dimension")).as_deref() == Some(dim)
|
||||
&& game.world.chunks.contains_key(&(x >> 4, z >> 4))
|
||||
&& game
|
||||
.res
|
||||
.facts()
|
||||
.get(game.world.block(x, y, z))
|
||||
.name
|
||||
.as_ref()
|
||||
!= "minecraft:lodestone"
|
||||
};
|
||||
for slot in (0..self.inventory.len()).chain([super::shield::OFFHAND_SLOT]) {
|
||||
if self.stack_in(slot).is_some_and(|s| gone(self, s))
|
||||
&& let Some(s) = self.stack_in_mut(slot)
|
||||
{
|
||||
s.data_mut().set_other(
|
||||
"minecraft:lodestone_tracker",
|
||||
Some(Tag::Compound(Default::default())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn give_or_drop_stack(&mut self, stack: ItemStack) {
|
||||
if let Some(rest) = self.give(stack)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
@@ -774,9 +868,8 @@ impl Game<'_> {
|
||||
(d * 2.0 + 0.5) as i8
|
||||
}
|
||||
};
|
||||
let y = f64::from(yaw);
|
||||
let adjusted = if y < 0.0 { y - 8.0 } else { y + 8.0 };
|
||||
let rot = ((adjusted * 16.0 / 360.0) as i32 & 15) as u8;
|
||||
// calculateRotation: the arrow spins in the Nether.
|
||||
let rot = decoration_rotation(nether, game_time, f64::from(yaw));
|
||||
let sprite = if inside {
|
||||
Some("map/decorations/player")
|
||||
} else if dx.abs() < 320.0 && dz.abs() < 320.0 {
|
||||
@@ -1114,9 +1207,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// `MapRenderer.render`: the map texture and its decorations under `m`
|
||||
/// (map pixels 0..128, y down), drawn at depth offsets toward the viewer.
|
||||
/// `show_only_frame` (a map in an item frame) leaves out the decorations that
|
||||
/// do not show on frames.
|
||||
pub(super) fn map_vertices(
|
||||
&mut self,
|
||||
id: i32,
|
||||
show_only_frame: bool,
|
||||
m: glam::Mat4,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
@@ -1158,14 +1254,18 @@ impl Game<'_> {
|
||||
.get(&id)
|
||||
.map(|m| m.decorations.clone())
|
||||
.unwrap_or_default();
|
||||
for (i, d) in decorations.iter().enumerate() {
|
||||
let mut count = 0;
|
||||
for d in &decorations {
|
||||
if show_only_frame && !shown_on_frame(d.sprite) {
|
||||
continue;
|
||||
}
|
||||
let Some(s) = self.gui.atlas.sprites.get(d.sprite).copied() else {
|
||||
continue;
|
||||
};
|
||||
let dm = glam::Mat4::from_translation(glam::Vec3::new(
|
||||
f32::from(d.x) / 2.0 + 64.0,
|
||||
f32::from(d.y) / 2.0 + 64.0,
|
||||
-0.02 - i as f32 * 0.001,
|
||||
-0.02 - count as f32 * 0.001,
|
||||
)) * glam::Mat4::from_rotation_z(
|
||||
(f32::from(d.rot) * 360.0 / 16.0).to_radians(),
|
||||
) * glam::Mat4::from_scale(glam::Vec3::splat(4.0))
|
||||
@@ -1187,6 +1287,7 @@ impl Game<'_> {
|
||||
[p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)],
|
||||
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
|
||||
@@ -1255,7 +1356,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if has_data {
|
||||
self.map_vertices(id, m, light, out);
|
||||
self.map_vertices(id, false, m, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,50 +646,6 @@ impl Game<'_> {
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
|
||||
}
|
||||
/// `MinecartHopper.suckInItems`: items above, into the five slots.
|
||||
fn hopper_cart_pickup(&mut self, c: &mut Minecart) {
|
||||
if !c.enabled {
|
||||
return;
|
||||
}
|
||||
let p = c.position;
|
||||
let zone = Aabb::new(
|
||||
Vec3::new(p.x - 0.5, p.y - 0.5 + 0.0, p.z - 0.5),
|
||||
Vec3::new(p.x + 0.5, p.y + 1.5, p.z + 0.5),
|
||||
);
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let e = &self.dropped[i];
|
||||
if e.pickup_delay > 0 || !zone.contains(e.position) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut stack = e.stack.clone();
|
||||
for slot in c.items.iter_mut() {
|
||||
match slot {
|
||||
None => {
|
||||
*slot = Some(stack.clone());
|
||||
stack.count = 0;
|
||||
}
|
||||
Some(s) if s.same_item_same_components(&stack) => {
|
||||
let max = self.res.items().get(&s.item).map_or(64, |i| i.max_stack);
|
||||
let moved = stack.count.min(max.saturating_sub(s.count));
|
||||
s.count += moved;
|
||||
stack.count -= moved;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if stack.count == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if stack.count == 0 {
|
||||
self.dropped.remove(i);
|
||||
} else {
|
||||
self.dropped[i].stack = stack;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.push` from each player walking into a cart
|
||||
/// (`LivingEntity.pushEntities`): the window's player and every server
|
||||
/// player (single player's own on its server thread, LAN guests). A
|
||||
@@ -713,12 +669,7 @@ impl Game<'_> {
|
||||
host.hub.here(g) && g.loaded && !g.dead && !g.spectator() && g.vehicle.is_none()
|
||||
}) {
|
||||
let [x, y, z] = g.position;
|
||||
let height = if g.sneaking { 1.5 } else { 1.8 };
|
||||
let bb = Aabb::new(
|
||||
Vec3::new(x - 0.3, y, z - 0.3),
|
||||
Vec3::new(x + 0.3, y + height, z + 0.3),
|
||||
);
|
||||
pushers.push((Vec3::new(x, y, z), bb));
|
||||
pushers.push((Vec3::new(x, y, z), g.bounds()));
|
||||
}
|
||||
}
|
||||
for (feet, bb) in pushers {
|
||||
@@ -827,8 +778,8 @@ impl Game<'_> {
|
||||
}
|
||||
let mut c = self.carts[i].clone();
|
||||
let alive = self.tick_cart(&mut c, [0.0; 2]);
|
||||
if c.kind == CartKind::Hopper {
|
||||
self.hopper_cart_pickup(&mut c);
|
||||
if c.kind == CartKind::Hopper && c.enabled {
|
||||
self.hopper_cart_suck(&mut c);
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
|
||||
@@ -384,13 +384,15 @@ impl Game<'_> {
|
||||
owner.use_item_remaining_ticks = 72_000 - mob.extra.using_item;
|
||||
owner.crossbow_charge_duration = 25;
|
||||
}
|
||||
let context = if left {
|
||||
horizon_assets::item_model::DisplayContext::ThirdPersonLeftHand
|
||||
} else {
|
||||
horizon_assets::item_model::DisplayContext::ThirdPersonRightHand
|
||||
use horizon_assets::item_model::DisplayContext as Context;
|
||||
// PandaHoldsItemLayer shows the item as it lies on the ground.
|
||||
let (context, ordinal) = match (mob.species(), left) {
|
||||
(Species::Panda, _) => (Context::Ground, 7),
|
||||
(_, true) => (Context::ThirdPersonLeftHand, 1),
|
||||
(_, false) => (Context::ThirdPersonRightHand, 2),
|
||||
};
|
||||
// `updateForLiving`: seeded by the entity id plus the context ordinal.
|
||||
let seed = mob.id as i32 + if left { 1 } else { 2 };
|
||||
let seed = mob.id as i32 + ordinal;
|
||||
let state = self.resolve_item(&stack, context, Some(&owner), seed);
|
||||
if lit {
|
||||
self.submit_item_world(&state, t, light, out);
|
||||
|
||||
@@ -5,11 +5,15 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
mobs::{
|
||||
Species,
|
||||
drops::{self, SLOTS},
|
||||
finalize::equip_stack,
|
||||
gear::{self, ARMOR, MAIN},
|
||||
spawner::equipment_table,
|
||||
},
|
||||
raid::{self, RaiderType},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_world::{loot::EntityLootParams, nbt::Tag};
|
||||
|
||||
/// `EnchantmentsByCostWithDifficulty` of `mob_spawn_equipment`.
|
||||
const MIN_COST: i32 = 5;
|
||||
@@ -47,6 +51,58 @@ fn slot_item(m: &horizon_client::mobs::Mob, slot: usize) -> Option<std::sync::Ar
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Mob.equip(EquipmentTable)` for a spawner's `equipment`: the loot table
|
||||
/// (the mob as `this`, its random sequence) is rolled and each stack goes
|
||||
/// in the slot `EquipmentUser.resolveSlot` picks, with the table's drop
|
||||
/// chance for that slot.
|
||||
pub(super) fn equip_from_table(&mut self, id: u32, table: &Tag) {
|
||||
let Some((name, chances)) = equipment_table(table) else {
|
||||
return;
|
||||
};
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if !loot.has_table(&name) {
|
||||
return;
|
||||
}
|
||||
let params = EntityLootParams {
|
||||
this: drops::loot_entity(m, None),
|
||||
..EntityLootParams::default()
|
||||
};
|
||||
let seed = self.level.seed;
|
||||
let stacks = match loot.random_sequence(&name) {
|
||||
Some(sequence) => {
|
||||
let random = self.rand_sequences.get(sequence, seed);
|
||||
loot.roll_entity(&name, random, ¶ms)
|
||||
}
|
||||
None => loot.roll_entity(&name, &mut self.world.random, ¶ms),
|
||||
};
|
||||
let mut inserted: Vec<usize> = Vec::new();
|
||||
for stack in stacks.iter().filter(|s| !s.is_empty()) {
|
||||
// `resolveSlot`: an `equippable` item goes in its own slot, any
|
||||
// other in the main hand, each slot once.
|
||||
let slot = match self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_slot.as_deref())
|
||||
{
|
||||
Some(name) => SLOTS.iter().position(|s| *s == name),
|
||||
None => Some(0),
|
||||
};
|
||||
let Some(slot) = slot.filter(|s| !inserted.contains(s)) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
equip_stack(m, slot, stack, chances[slot]);
|
||||
}
|
||||
inserted.push(slot);
|
||||
}
|
||||
}
|
||||
/// `Mob.populateDefaultEquipmentEnchantments`: the weapon one time in
|
||||
/// four and each piece of armour one time in two, scaled by the local
|
||||
/// difficulty, enchanted as if at an enchanting table of cost 5 to 22.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The looping sound instances of mobs: a bee's buzz
|
||||
//! (`ClientPacketListener.postAddEntitySoundInstance`) and a guardian's
|
||||
//! attack hum (entity event 21). The sound engine ticks them with what
|
||||
//! `start_mob_sounds` reads off the mobs.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
mobs::{Species, bee, guardian},
|
||||
sound_engine::{MobSound, SoundEngine, SoundInstance, Sounds},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Start the buzz of each bee that has none (or whose anger changed, which
|
||||
/// swaps the flying loop for the aggressive one and back), forget the mobs
|
||||
/// that are gone, and read what the instances follow.
|
||||
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
|
||||
let game_time = self.world.game_time;
|
||||
let mobs: Vec<MobSound> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
!m.removed
|
||||
&& matches!(
|
||||
m.species(),
|
||||
Species::Bee | Species::Guardian | Species::ElderGuardian
|
||||
)
|
||||
})
|
||||
.map(|m| MobSound {
|
||||
id: m.id,
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
// Vec3.horizontalDistance.
|
||||
speed: m.velocity.x.hypot(m.velocity.z),
|
||||
baby: m.is_baby(),
|
||||
// NeutralMob.isAngry: the anger end time is ahead.
|
||||
angry: m.species() == Species::Bee
|
||||
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
|
||||
silent: m.silent,
|
||||
attack_scale: guardian::attack_scale(m),
|
||||
})
|
||||
.collect();
|
||||
self.guardian_sounds
|
||||
.retain(|id| mobs.iter().any(|m| m.id == *id));
|
||||
self.bee_sounds
|
||||
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
|
||||
for sound in &mobs {
|
||||
let is_bee = self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == sound.id && m.species() == Species::Bee);
|
||||
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
|
||||
self.bee_sounds.insert(sound.id, sound.angry);
|
||||
let instance = SoundInstance::bee(sound, sound.angry, SoundEngine::unseeded());
|
||||
self.audio.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
mobs
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleEntityEvent` 21: a guardian's attack
|
||||
/// hum starts (one loop per guardian; the loops of its earlier beams are
|
||||
/// the same loop, as each one follows the guardian's charge).
|
||||
pub(super) fn start_guardian_sound(&mut self, id: u32) {
|
||||
let Some(m) = self.mobs.iter().find(|m| {
|
||||
m.id == id && matches!(m.species(), Species::Guardian | Species::ElderGuardian)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
if !self.guardian_sounds.insert(id) {
|
||||
return;
|
||||
}
|
||||
let sound = MobSound {
|
||||
id,
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
speed: 0.0,
|
||||
baby: false,
|
||||
angry: false,
|
||||
silent: m.silent,
|
||||
attack_scale: guardian::attack_scale(m),
|
||||
};
|
||||
let instance = SoundInstance::guardian_attack(&sound, SoundEngine::unseeded());
|
||||
self.audio.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
@@ -253,10 +253,7 @@ impl Game<'_> {
|
||||
/// `LocalPlayer.aiStep`'s riding jump: the bar charges while jump is
|
||||
/// held; on release, the jump's strength (0 when there is none).
|
||||
pub(super) fn charge_mount_jump(&mut self, i: usize, keys: Keys) -> f32 {
|
||||
// PlayerRideableJumping: horses, camels and nautili (the dash).
|
||||
let saddled = self.mobs[i].extra.saddled
|
||||
&& (equine::is_equine(self.mobs[i].species())
|
||||
|| horizon_client::mobs::nautilus::is_nautilus(self.mobs[i].species()));
|
||||
let saddled = self.mobs[i].extra.saddled && Self::jumps(self.mobs[i].species());
|
||||
let mut jump = 0.0;
|
||||
if saddled {
|
||||
let st = &mut self.mount;
|
||||
@@ -463,10 +460,15 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Player.jumpableVehicle`: a saddled mount that jumps.
|
||||
/// `PlayerRideableJumping`: horses, camels and nautili (the dash).
|
||||
fn jumps(species: Species) -> bool {
|
||||
equine::is_equine(species) || horizon_client::mobs::nautilus::is_nautilus(species)
|
||||
}
|
||||
/// `Player.jumpableVehicle`: a saddled mount that jumps (not a pig or a
|
||||
/// strider, which a saddle only steers).
|
||||
pub(super) fn jumpable_mount(&self) -> bool {
|
||||
self.mount_index()
|
||||
.is_some_and(|i| self.mobs[i].extra.saddled)
|
||||
.is_some_and(|i| self.mobs[i].extra.saddled && Self::jumps(self.mobs[i].species()))
|
||||
}
|
||||
/// `willPrioritizeJumpInfo`: charging a jump or recharging.
|
||||
pub(super) fn mount_jumping(&self) -> bool {
|
||||
@@ -491,8 +493,13 @@ impl Game<'_> {
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
if jump_bar {
|
||||
// JumpableVehicleBar: the cooldown shows while the jump recharges.
|
||||
let cooldown = i32::from(self.mount.jump_ticks < 0);
|
||||
// JumpableVehicleBar: `getJumpCooldown`, which only the camel's and
|
||||
// the nautilus' dash have; a horse always shows the progress.
|
||||
let cooldown = i32::from(
|
||||
self.mount.jump_ticks < 0
|
||||
&& (matches!(m.species(), Species::Camel | Species::CamelHusk)
|
||||
|| horizon_client::mobs::nautilus::is_nautilus(m.species())),
|
||||
);
|
||||
let draws =
|
||||
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
|
||||
@@ -34,11 +34,13 @@ impl Game<'_> {
|
||||
self.sound_paused = self.pause;
|
||||
}
|
||||
let (vehicle, carts) = self.start_cart_sounds();
|
||||
let mobs = self.start_mob_sounds();
|
||||
let ctx = TickContext {
|
||||
player_underwater: self.ambience.was_underwater(),
|
||||
player_removed: self.player.is_none(),
|
||||
vehicle,
|
||||
carts,
|
||||
mobs,
|
||||
frozen: !self.clock.runs_normally(),
|
||||
};
|
||||
self.audio.tick(self.pause, &ctx);
|
||||
|
||||
@@ -338,7 +338,9 @@ impl Game<'_> {
|
||||
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
|
||||
// Text space: x right, y down (scale 0.025, -0.025).
|
||||
let at = |x: f32, y: f32| o + r * x - u * y;
|
||||
let chars: Vec<(char, u32)> = text
|
||||
// Component.getVisualOrderText.
|
||||
let shown = horizon_client::bidi::visual(text);
|
||||
let chars: Vec<(char, u32)> = shown
|
||||
.spans
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
|
||||
@@ -504,7 +504,8 @@ impl<'a> Game<'a> {
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent,
|
||||
persistent: m.persistent
|
||||
|| horizon_client::mobs::ai::requires_custom_persistence(m),
|
||||
})
|
||||
.collect();
|
||||
let players = players
|
||||
|
||||
@@ -505,6 +505,17 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Entity.setPortalCooldown` on an entity that has just spawned in a
|
||||
/// portal: it stands in the portal for 300 ticks without going through.
|
||||
pub(super) fn set_portal_cooldown(&mut self, id: u32) {
|
||||
self.portals.entities.insert(
|
||||
id,
|
||||
PortalState {
|
||||
cooldown: ENTITY_DELAY,
|
||||
process: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
/// `canUsePortal(false)` of a mob: alive, riding nothing, not asleep,
|
||||
/// not carrying the player, and not a boss that never travels.
|
||||
fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Pandas on the game side: the food they find on the ground and pick up
|
||||
//! (`Mob.aiStep`, `Panda.pickUpItem`), the bamboo a player gives them
|
||||
//! (`Panda.mobInteract`), the start a sneeze gives the grown pandas about,
|
||||
//! and the crumbs and the sneeze a window draws.
|
||||
use super::{
|
||||
Game,
|
||||
pickups::{Collector, Taken},
|
||||
};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::{Species, panda},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Panda.canPickUpAndEat`'s tag.
|
||||
fn panda_eats_from_ground(&self, item: &str) -> bool {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:panda_eats_from_ground")
|
||||
.is_some_and(|tag| tag.contains(item))
|
||||
}
|
||||
/// A panda takes what lies at its feet when its mouth is free, and
|
||||
/// looks for food within 6 and 8 blocks for `PandaSitGoal`.
|
||||
pub(super) fn tick_pandas(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Panda || !m.alive() {
|
||||
continue;
|
||||
}
|
||||
let id = m.id;
|
||||
let wants = |g: &Self, e: &horizon_client::entities::ItemEntity| {
|
||||
e.pickup_delay == 0 && g.panda_eats_from_ground(&e.stack.item)
|
||||
};
|
||||
// Mob.aiStep: the items within the box grown by (1, 0, 1).
|
||||
if griefing && m.can_pick_up_loot() && m.extra.main_hand.is_none() {
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
let taken = self
|
||||
.dropped
|
||||
.iter()
|
||||
.position(|e| wants(self, e) && reach.intersects(e.bounds()));
|
||||
if let Some(j) = taken {
|
||||
let e = self.dropped.remove(j);
|
||||
self.add_pickup(Taken::Item(Box::new(e.clone())), Collector::Mob(id));
|
||||
panda::hold(&mut self.mobs[i], &e.stack.item, e.stack.count, true);
|
||||
}
|
||||
}
|
||||
let m = &self.mobs[i];
|
||||
let area = |r: f64| m.bounds().inflate(Vec3::new(r, r, r));
|
||||
let (near, wide) = (area(6.0), area(8.0));
|
||||
let food_near = self
|
||||
.dropped
|
||||
.iter()
|
||||
.any(|e| wants(self, e) && near.intersects(e.bounds()));
|
||||
let food_at = self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|e| wants(self, e) && wide.intersects(e.bounds()))
|
||||
.map(|e| [e.position.x, e.position.y, e.position.z]);
|
||||
panda::set_food(&mut self.mobs[i], food_near, food_at);
|
||||
}
|
||||
}
|
||||
/// `Panda.mobInteract` for a click with `item`; whether the click is taken.
|
||||
pub(super) fn interact_panda(&mut self, index: usize, item: &str) -> bool {
|
||||
let creative = self.creative;
|
||||
let m = &mut self.mobs[index];
|
||||
if panda::scared(m) {
|
||||
return false;
|
||||
}
|
||||
if panda::get_up(m) {
|
||||
return true;
|
||||
}
|
||||
if !m.kind.food.iter().any(|f| f == item) {
|
||||
return false;
|
||||
}
|
||||
if m.ai.target.is_some() {
|
||||
panda::got_bamboo(m);
|
||||
}
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let mut dropped = None;
|
||||
if m.is_baby() {
|
||||
// ageUp((int) (-getAge() / 20 * 0.1F), true).
|
||||
let seconds = ((-m.extra.age / 20) as f32 * 0.1) as i32;
|
||||
horizon_client::mobs::ai::age_up(m, seconds, true);
|
||||
} else if m.extra.age == 0 && m.extra.in_love <= 0 {
|
||||
m.extra.in_love = 600;
|
||||
m.extra.love_player = true;
|
||||
} else {
|
||||
if panda::is_sitting(m) || m.fluids.in_water {
|
||||
return false;
|
||||
}
|
||||
panda::try_to_sit(m);
|
||||
panda::start_eating(m);
|
||||
// What it held falls, unless the player has infinite materials.
|
||||
if let Some(held) = m.extra.main_hand.clone()
|
||||
&& !creative
|
||||
{
|
||||
dropped = Some(ItemStack::new(&held, panda::held_count(m)));
|
||||
}
|
||||
panda::hold(m, item, 1, false);
|
||||
}
|
||||
self.consume_held();
|
||||
if let Some(stack) = dropped {
|
||||
self.spawn_item(stack, at, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `Panda.afterSneeze`: the grown pandas within 10 blocks of a cub jump.
|
||||
pub(super) fn panda_sneezed(&mut self, id: u32) {
|
||||
let Some(area) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.bounds().inflate(Vec3::new(10.0, 10.0, 10.0)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for m in &mut self.mobs {
|
||||
if m.species() == Species::Panda
|
||||
&& !m.is_baby()
|
||||
&& m.on_ground
|
||||
&& !m.fluids.in_water
|
||||
&& panda::can_perform_action(m, panda::scared(m))
|
||||
&& area.intersects(m.bounds())
|
||||
{
|
||||
// LivingEntity.jumpFromGround.
|
||||
m.needs_sync = true;
|
||||
let power = f64::from(m.jump_power());
|
||||
if power > 1.0e-5 {
|
||||
m.velocity.y = power.max(m.velocity.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What a window draws of its pandas: the crumbs of a bite
|
||||
/// (`addEatingParticles`) and the sneeze's particle.
|
||||
pub(super) fn panda_effects(&mut self) {
|
||||
let mut bites = Vec::new();
|
||||
let mut sneezes = Vec::new();
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.species() == Species::Panda)
|
||||
{
|
||||
let (crumbs, sneeze) = panda::effects(m);
|
||||
if let (Some(crumbs), Some(item)) = (crumbs, m.extra.main_hand.clone()) {
|
||||
bites.push((item, crumbs));
|
||||
}
|
||||
sneezes.extend(sneeze);
|
||||
}
|
||||
for (item, crumbs) in bites {
|
||||
let options = self.item_particle(&ItemStack::new(&item, 1));
|
||||
for (at, velocity) in crumbs {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
let options = ParticleOptions::simple(ParticleType::Sneeze);
|
||||
for (at, velocity) in sneezes {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,11 +279,13 @@ impl Game<'_> {
|
||||
/// horse is a trap for fifteen minutes; a player within ten blocks springs
|
||||
/// it.
|
||||
pub(super) fn tick_skeleton_traps(&mut self) {
|
||||
let player = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| !p.health.dead_or_dying())
|
||||
.map(|p| p.position);
|
||||
// `Level.hasNearbyAlivePlayer`: alive, not a spectator, in the level.
|
||||
let players: Vec<_> = self
|
||||
.level_players()
|
||||
.into_iter()
|
||||
.filter(|p| p.alive && !p.spectator)
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
let mut sprung = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
if m.species() != Species::SkeletonHorse || !m.extra.trap || !m.alive() {
|
||||
@@ -296,7 +298,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if player.is_some_and(|p| m.distance_sqr(p) < TRAP_RANGE * TRAP_RANGE) {
|
||||
if players
|
||||
.iter()
|
||||
.any(|&p| m.distance_sqr(p) < TRAP_RANGE * TRAP_RANGE)
|
||||
{
|
||||
sprung.push(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +443,7 @@ impl Game<'_> {
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let start = out.len();
|
||||
horizon_client::mobs::render::render_with(
|
||||
m,
|
||||
alpha as f32,
|
||||
@@ -453,9 +454,25 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
self.armor[1].as_ref().map(|s| &*s.item),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
|
||||
// in its team by its profile name.
|
||||
if self.potions.effects.get("glowing").is_some() {
|
||||
let team = match &self.remote {
|
||||
Some(r) => self.score.board.players_team(&r.profile_name),
|
||||
None => self.player_team(),
|
||||
};
|
||||
self.mark_outline(&out[start..], super::glowing::team_color(team));
|
||||
}
|
||||
}
|
||||
/// `extractEntityInInventoryFollowsMouse`: the player in the box
|
||||
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
|
||||
@@ -509,7 +526,14 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
&mut verts,
|
||||
);
|
||||
self.player_elytra_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.player_elytra_geometry(
|
||||
&m,
|
||||
self.armor[1].as_ref().map(|s| &*s.item),
|
||||
camera,
|
||||
1.0,
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
let height = if m.extra.crouching { 1.5 } else { 1.8 };
|
||||
// PictureInPictureRenderer: the box centre, (size, size, -size);
|
||||
@@ -548,17 +572,18 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn player_elytra_geometry(
|
||||
/// `WingsLayer` for a player wearing `chest`.
|
||||
pub(super) fn player_elytra_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
chest: Option<&str>,
|
||||
camera: glam::DVec3,
|
||||
partial: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(asset) = self.armor[1]
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
let Some(asset) = chest
|
||||
.and_then(|item| self.res.items().get(item))
|
||||
.and_then(|i| i.equip_asset.as_ref())
|
||||
else {
|
||||
return;
|
||||
@@ -598,7 +623,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `SpinAttackEffectLayer`: Riptide's two boxes turning around a spinning
|
||||
/// player (`SpinAttackEffectModel.setupAnim`).
|
||||
fn spin_attack_geometry(
|
||||
pub(super) fn spin_attack_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
camera: glam::DVec3,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Pick Block on an entity (`Minecraft.pickBlockOrEntity`'s entity case and
|
||||
//! `ServerGamePacketListenerImpl.handlePickItemFromEntity`): the client aims,
|
||||
//! the server answers with `Entity.getPickResult`.
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The entity the crosshair hits before any block, as `Entity.getId`; an
|
||||
/// entity with nothing to pick (a projectile, a player, the dragon) is
|
||||
/// `Some(None)`. `None` when the view hits a block or nothing.
|
||||
pub(super) fn pick_entity_hit(&self) -> Option<Option<i32>> {
|
||||
let mut best: Option<(f64, Option<i32>)> = None;
|
||||
let mut offer = |distance: f64, id: Option<i32>| {
|
||||
if self.before_block(distance) && best.is_none_or(|b| distance < b.0) {
|
||||
best = Some((distance, id));
|
||||
}
|
||||
};
|
||||
if let Some(hit) = self.remote_object_hit(true) {
|
||||
offer(hit.distance, Some(hit.id));
|
||||
}
|
||||
if let Some((i, d)) = self.mob_hit() {
|
||||
offer(d, Some(self.mobs[i].id as i32));
|
||||
}
|
||||
if let Some((_, d)) = self.guest_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
if let Some((_, d)) = self.redirectable_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
if let Some((_, d)) = self.dragon_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
/// `Entity.getPickResult` of the entity `id`: a mob's spawn egg, the item
|
||||
/// of a boat, minecart, armor stand, cushion, painting, item frame (the
|
||||
/// framed item when it holds one) or end crystal.
|
||||
pub(super) fn entity_pick_result(&self, id: i32) -> Option<ItemStack> {
|
||||
let id = u32::try_from(id).ok()?;
|
||||
let item = if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
// Mob.getPickResult: SpawnEggItem.byId.
|
||||
let egg = format!("{}_spawn_egg", m.kind.id);
|
||||
return self.res.items().get(&egg).map(|_| ItemStack::new(&egg, 1));
|
||||
} else if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
b.entity_type()
|
||||
} else if let Some(c) = self.carts.iter().find(|c| c.id == id) {
|
||||
c.kind.id().to_owned()
|
||||
} else if self.armor_stands.iter().any(|a| a.id == id) {
|
||||
"minecraft:armor_stand".to_owned()
|
||||
} else if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
|
||||
c.item()
|
||||
} else if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
|
||||
// ItemFrame.getPickResult: what it frames, else the frame itself.
|
||||
return Some(
|
||||
f.item
|
||||
.clone()
|
||||
.unwrap_or_else(|| ItemStack::new(f.kind(), 1)),
|
||||
);
|
||||
} else if self.paintings.iter().any(|p| p.id == id) {
|
||||
"minecraft:painting".to_owned()
|
||||
} else if self.crystals.iter().any(|c| c.id == id) {
|
||||
"minecraft:end_crystal".to_owned()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(ItemStack::new(&item, 1))
|
||||
}
|
||||
|
||||
/// `Player.isWithinEntityInteractionRange(entity, 3.0)`: the entity's box
|
||||
/// within the interaction range plus 3 of the eyes.
|
||||
pub(super) fn within_entity_range(&self, id: i32) -> bool {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return false;
|
||||
};
|
||||
let mob = u32::try_from(id)
|
||||
.ok()
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.bounds());
|
||||
let Some(b) = self
|
||||
.object_target(id)
|
||||
.map(|(lo, hi, _)| (lo, hi))
|
||||
.or_else(|| {
|
||||
mob.or_else(|| self.tracked_special_box(id)).map(|b| {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
})
|
||||
})
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let d2: f64 = (0..3)
|
||||
.map(|a| (eye[a] - eye[a].clamp(b.0[a], b.1[a])).powi(2))
|
||||
.sum();
|
||||
let range = if self.creative { 5.0 } else { 3.0 } + 3.0;
|
||||
d2 < range * range
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ pub(super) struct ThrownPotion {
|
||||
owner: Option<Text>,
|
||||
}
|
||||
/// A mob's effect ids carry the `minecraft:` namespace.
|
||||
fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
@@ -806,9 +806,28 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row.
|
||||
/// `Screen.showsActiveEffects`: the survival and creative inventories
|
||||
/// draw their own effect list when `EffectsInInventory.canSeeEffects`
|
||||
/// (32 or more pixels right of the screen).
|
||||
fn screen_shows_active_effects(&self, batch: &HudBatch) -> bool {
|
||||
let (ox, image_width) = match self.screen {
|
||||
Screen::Inventory
|
||||
if self.container.is_none() && self.craft_size == 2 && !self.mount_active() =>
|
||||
{
|
||||
(self.inventory_origin(batch).0, 176)
|
||||
}
|
||||
Screen::Creative => (
|
||||
Self::creative_origin(batch).0,
|
||||
horizon_client::creative::Creative::WIDTH as i32,
|
||||
),
|
||||
_ => return false,
|
||||
};
|
||||
batch.width as i32 - (ox as i32 + image_width + 2) >= 32
|
||||
}
|
||||
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row;
|
||||
/// every screen but the inventories keeps them.
|
||||
pub(super) fn draw_effect_icons(&self, batch: &mut HudBatch) {
|
||||
if self.screen != Screen::Playing || self.potions.effects.list.is_empty() {
|
||||
if self.potions.effects.list.is_empty() || self.screen_shows_active_effects(batch) {
|
||||
return;
|
||||
}
|
||||
let icons: Vec<hud_overlays::EffectIcon> = self
|
||||
|
||||
@@ -53,7 +53,11 @@ impl Game<'_> {
|
||||
if let Some(player) = self.player.as_mut() {
|
||||
player.fire_ticks = 0;
|
||||
}
|
||||
// Entity.playEntityOnFireExtinguishedSound
|
||||
self.extinguish_sound(at);
|
||||
Ok(())
|
||||
}
|
||||
/// Entity.playEntityOnFireExtinguishedSound
|
||||
fn extinguish_sound(&mut self, at: [f64; 3]) {
|
||||
let r = self.particles.random();
|
||||
let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4;
|
||||
self.audio.play(
|
||||
@@ -62,7 +66,29 @@ impl Game<'_> {
|
||||
0.7,
|
||||
pitch,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
/// `Entity.applyEffectsFromBlocks`: a burning player in the rain
|
||||
/// (`isInRain`) has the fire put out (`clearFire`), with the extinguish
|
||||
/// sound that everyone near hears.
|
||||
pub(super) fn rain_extinguish_player(&mut self) {
|
||||
let Some(player) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let (at, top) = (player.position, player.bounds().max_position().y);
|
||||
if player.fire_ticks <= 0 || !self.in_rain(at, top) {
|
||||
return;
|
||||
}
|
||||
if let Some(player) = self.player.as_mut() {
|
||||
player.fire_ticks = 0;
|
||||
}
|
||||
self.extinguish_sound([at.x, at.y, at.z]);
|
||||
}
|
||||
/// The in-window player's rain extinguish (a server's player has its
|
||||
/// fire put out by the server).
|
||||
pub(super) fn rain_extinguish_in_frame(&mut self) {
|
||||
if self.remote.is_none() {
|
||||
self.rain_extinguish_player();
|
||||
}
|
||||
}
|
||||
/// Whether the block at `pos` is powder snow.
|
||||
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::collections::{HashMap, HashSet};
|
||||
#[derive(Default)]
|
||||
pub(super) struct Raiding {
|
||||
patrol: PatrolSpawner,
|
||||
siege: Siege,
|
||||
/// The raids of the dimension the player is in, and that dimension.
|
||||
raids: Raids,
|
||||
loaded: Option<Dim>,
|
||||
@@ -38,6 +39,17 @@ pub(super) struct Raiding {
|
||||
homes: HashMap<i32, (i64, std::sync::Arc<Vec<[i32; 3]>>)>,
|
||||
}
|
||||
|
||||
/// `VillageSiege`: whether tonight is a siege, whether the spot is chosen,
|
||||
/// and the zombies still to spawn there.
|
||||
#[derive(Default)]
|
||||
struct Siege {
|
||||
tonight: bool,
|
||||
set_up: bool,
|
||||
zombies_to_spawn: i32,
|
||||
next_spawn_time: i32,
|
||||
spot: [i32; 3],
|
||||
}
|
||||
|
||||
impl Raiding {
|
||||
/// Exchange the level-held parts (`ServerLevel.getRaids`) with another
|
||||
/// level's (game/levels.rs).
|
||||
@@ -102,11 +114,12 @@ impl RaidLevel for Lvl<'_, '_> {
|
||||
&& g.res.facts().is_air(g.world.block(x, y, z))
|
||||
}
|
||||
fn player(&self) -> Option<RaidPlayer> {
|
||||
let p = self.game.player.as_ref()?;
|
||||
// The first player in the level: a server has no player of its own.
|
||||
let p = self.game.level_players().into_iter().next()?;
|
||||
Some(RaidPlayer {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
block: block_of(p.position),
|
||||
alive: !p.health.dead_or_dying(),
|
||||
alive: p.alive,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -174,12 +187,13 @@ impl PatrolLevel for PatrolCx<'_, '_, '_> {
|
||||
fn bright_outside(&self) -> bool {
|
||||
self.level.game.dim == Dim::Overworld && self.level.game.sky_darken() < 4
|
||||
}
|
||||
/// `PatrolSpawner.tick` picks from `level.players()`: the local player
|
||||
/// and every guest in the level.
|
||||
fn patrol_players(&self) -> Vec<([i32; 3], bool)> {
|
||||
let g = self.level.game;
|
||||
g.player
|
||||
.as_ref()
|
||||
.map(|p| (block_of(p.position), g.spectator()))
|
||||
.into_iter()
|
||||
self.level
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| (block_of(p.position), p.spectator))
|
||||
.collect()
|
||||
}
|
||||
fn sections_to_village(&self, pos: [i32; 3]) -> i32 {
|
||||
@@ -633,7 +647,7 @@ impl Game<'_> {
|
||||
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
|
||||
/// wave, for players within 64 blocks or in the raid.
|
||||
fn raid_horn(&mut self, id: i32, pos: [i32; 3]) {
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
let Some(p) = self.level_players().first().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let in_raid = self.raiding.raids.get(id).is_some_and(|r| r.bar.player);
|
||||
@@ -762,7 +776,7 @@ impl Game<'_> {
|
||||
}
|
||||
positions.push((m.id, m.position, s.active_raid));
|
||||
}
|
||||
let banners: Vec<(usize, Vec3)> = self
|
||||
let banners: Vec<(usize, u32, Vec3)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -771,7 +785,7 @@ impl Game<'_> {
|
||||
&& e.stack.data().is_some_and(|d| d.ominous_banner)
|
||||
&& e.pickup_delay == 0
|
||||
})
|
||||
.map(|(i, e)| (i, e.position))
|
||||
.map(|(i, e)| (i, e.id, e.position))
|
||||
.collect();
|
||||
let mut taken: Vec<usize> = Vec::new();
|
||||
let mut collectors: Vec<(usize, u32)> = Vec::new();
|
||||
@@ -791,7 +805,7 @@ impl Game<'_> {
|
||||
let has_rider = ridden.contains(&m.id);
|
||||
let target_golem = matches!(m.ai.target, Some(horizon_client::mobs::Who::Mob(t)) if golems.contains(&t));
|
||||
let target_player = m.ai.target == Some(horizon_client::mobs::Who::Player);
|
||||
let (health, age, range) = (m.health, m.age, m.attrs.follow_range);
|
||||
let (health, range) = (m.health, m.attrs.follow_range);
|
||||
let raids = &mut self.raiding.raids;
|
||||
let s = m.extra.raider.get_or_insert_with(Box::default);
|
||||
s.rider = has_rider;
|
||||
@@ -821,14 +835,11 @@ impl Game<'_> {
|
||||
if !alive {
|
||||
continue;
|
||||
}
|
||||
// Raider.updateNoActionTime adds two per tick, and a raider
|
||||
// fighting a player or golem in a raid is never idle.
|
||||
if s.raid_id.is_some() {
|
||||
m.ai.no_action_time = if target_player || target_golem {
|
||||
0
|
||||
} else {
|
||||
m.ai.no_action_time + 1
|
||||
};
|
||||
// Raider.aiStep: a raider fighting a player or golem in a raid is
|
||||
// never idle. The per-tick growth (+1 serverAiStep, +2
|
||||
// Raider.updateNoActionTime) is added by the mob's own step.
|
||||
if s.raid_id.is_some() && (target_player || target_golem) {
|
||||
m.ai.no_action_time = 0;
|
||||
}
|
||||
// Raider.aiStep: join the raid the raider stands in.
|
||||
if s.raid_id.is_none()
|
||||
@@ -839,11 +850,9 @@ impl Game<'_> {
|
||||
{
|
||||
joins.push((m.id, id, health));
|
||||
}
|
||||
// PathfindToRaidGoal.recruitNearby.
|
||||
if s.active_raid
|
||||
&& !s.raid_over
|
||||
&& !s.in_village
|
||||
&& age % 20 == 0
|
||||
// PathfindToRaidGoal.tick: recruitNearby, only while the goal runs.
|
||||
if std::mem::take(&mut s.recruit_due)
|
||||
&& s.active_raid
|
||||
&& let Some(id) = s.raid_id
|
||||
{
|
||||
recruit.push((id, position));
|
||||
@@ -858,20 +867,33 @@ impl Game<'_> {
|
||||
&& raids.get(id).is_some_and(|r| r.leader(s.wave).is_none())
|
||||
&& !leaders.contains(&(id, s.wave))
|
||||
{
|
||||
let in_reach = |(i, _, p): &&(usize, u32, Vec3)| {
|
||||
!taken.contains(i)
|
||||
&& (p.x - position.x).abs() <= range
|
||||
&& (p.z - position.z).abs() <= range
|
||||
&& (p.y - position.y).abs() <= 8.0
|
||||
};
|
||||
let by_distance = |a: &&(usize, u32, Vec3), b: &&(usize, u32, Vec3)| {
|
||||
a.2.distance_squared(position)
|
||||
.total_cmp(&b.2.distance_squared(position))
|
||||
};
|
||||
// The unreachable cache only picks the navigation target;
|
||||
// a banner next to the raider is still picked up.
|
||||
let near = banners
|
||||
.iter()
|
||||
.filter(|(i, p)| {
|
||||
!taken.contains(i)
|
||||
&& (p.x - position.x).abs() <= range
|
||||
&& (p.z - position.z).abs() <= range
|
||||
&& (p.y - position.y).abs() <= 8.0
|
||||
.filter(in_reach)
|
||||
.filter(|(_, item, _)| {
|
||||
!s.unreachable_banners
|
||||
.iter()
|
||||
.any(|&(b, until)| b == *item && now < until)
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
a.1.distance_squared(position)
|
||||
.total_cmp(&b.1.distance_squared(position))
|
||||
});
|
||||
if let Some((i, p)) = near {
|
||||
.min_by(by_distance);
|
||||
let nearest = banners.iter().filter(in_reach).min_by(by_distance);
|
||||
let adjacent =
|
||||
nearest.filter(|(_, _, p)| p.distance_squared(position) < 1.414 * 1.414);
|
||||
if let Some((i, item, p)) = adjacent.or(near) {
|
||||
s.banner_at = Some([p.x, p.y, p.z]);
|
||||
s.banner_item = *item;
|
||||
if p.distance_squared(position) < 1.414 * 1.414 {
|
||||
// pickUpItem: the banner becomes the wave's leader.
|
||||
taken.push(*i);
|
||||
@@ -1036,6 +1058,7 @@ impl Game<'_> {
|
||||
/// read it).
|
||||
fn sync_villager_raids(&mut self, infos: &HashMap<i32, Vec<RaiderInfo>>) {
|
||||
let raids = &self.raiding.raids;
|
||||
let mut ongoing = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
if m.species() != Species::Villager {
|
||||
continue;
|
||||
@@ -1061,7 +1084,27 @@ impl Game<'_> {
|
||||
let ext = mobs_villager::ext_mut(m);
|
||||
ext.raid = status;
|
||||
ext.raid_wave = wave;
|
||||
if !m.no_ai && matches!(status, mobs_villager::PRE_RAID | mobs_villager::RAID) {
|
||||
ongoing.push(m.id);
|
||||
}
|
||||
}
|
||||
// Villager.customServerAiStep: a 1 in 100 chance each tick, in an
|
||||
// ongoing raid, of entity event 42 (`handleEntityEvent` splashes).
|
||||
for id in ongoing {
|
||||
if self.particles.random().next_int_bound(100) == Ok(0) {
|
||||
self.mob_raid_splash(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Entity event 42 for villager `id`: the guests get the byte, and this
|
||||
/// game draws `AbstractVillager.addParticlesAroundSelf(SPLASH)` itself.
|
||||
fn mob_raid_splash(&mut self, id: u32) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
self.lan_entity_event(id as i32, 42, at);
|
||||
self.particles_around_mob(id, "splash", horizon_client::particles::Burst::VILLAGER);
|
||||
}
|
||||
|
||||
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
|
||||
@@ -1187,6 +1230,140 @@ impl Game<'_> {
|
||||
self.spawn_event_mob("minecraft:pillager", at, m.leader, raider::Reason::Patrol);
|
||||
}
|
||||
}
|
||||
/// `VillageSiege.tick`: at midnight one night in ten, 20 zombies spawn
|
||||
/// two ticks apart near a village 32 blocks from a player standing in it.
|
||||
pub(super) fn tick_village_siege(&mut self) {
|
||||
if self.dim != Dim::Overworld {
|
||||
return;
|
||||
}
|
||||
let enemies = self.ui.rules.bool("spawn_mobs") && self.ui.rules.bool("spawn_monsters");
|
||||
if self.sky_darken() < 4 || !enemies {
|
||||
self.raiding.siege.tonight = false;
|
||||
self.raiding.siege.set_up = false;
|
||||
return;
|
||||
}
|
||||
// ClockTimeMarkers.ROLL_VILLAGE_SIEGE: tick 18000 of the 24000 tick day.
|
||||
if self.world.day_time.rem_euclid(24000) == 18000 {
|
||||
self.raiding.siege.tonight = self.world.random.next_int_bound(10).unwrap_or(1) == 0;
|
||||
}
|
||||
if !self.raiding.siege.tonight {
|
||||
return;
|
||||
}
|
||||
if !self.raiding.siege.set_up {
|
||||
if !self.try_to_setup_siege() {
|
||||
return;
|
||||
}
|
||||
self.raiding.siege.set_up = true;
|
||||
}
|
||||
if self.raiding.siege.next_spawn_time > 0 {
|
||||
self.raiding.siege.next_spawn_time -= 1;
|
||||
return;
|
||||
}
|
||||
self.raiding.siege.next_spawn_time = 2;
|
||||
if self.raiding.siege.zombies_to_spawn > 0 {
|
||||
self.try_spawn_siege_zombie();
|
||||
self.raiding.siege.zombies_to_spawn -= 1;
|
||||
} else {
|
||||
self.raiding.siege.tonight = false;
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.isVillage`: within one section of a village.
|
||||
fn is_village(centers: &HashSet<(i32, i32, i32)>, pos: [i32; 3]) -> bool {
|
||||
sections_to_village(centers, section(pos)) <= 1
|
||||
}
|
||||
/// `VillageSiege.tryToSetupSiege`: the first player standing in a village
|
||||
/// picks a spot 32 blocks away (ten tries); true when one stood in a
|
||||
/// village, even if no spot was found.
|
||||
fn try_to_setup_siege(&mut self) -> bool {
|
||||
let centers = self.village_centers();
|
||||
for player in self.level_players() {
|
||||
if player.spectator {
|
||||
continue;
|
||||
}
|
||||
let center = block_of(player.position);
|
||||
if !Self::is_village(¢ers, center)
|
||||
|| self.natural_level().biome_tag(
|
||||
"minecraft:without_zombie_sieges",
|
||||
center[0],
|
||||
center[1],
|
||||
center[2],
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for _ in 0..10 {
|
||||
let angle = self.world.random.next_float() * std::f32::consts::TAU;
|
||||
let spot = [
|
||||
center[0] + (angle.cos() * 32.0).floor() as i32,
|
||||
center[1],
|
||||
center[2] + (angle.sin() * 32.0).floor() as i32,
|
||||
];
|
||||
if self.find_siege_spawn_pos(¢ers, spot).is_some() {
|
||||
self.raiding.siege.spot = spot;
|
||||
self.raiding.siege.next_spawn_time = 0;
|
||||
self.raiding.siege.zombies_to_spawn = 20;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `VillageSiege.trySpawn`: one zombie, in a random yaw.
|
||||
fn try_spawn_siege_zombie(&mut self) {
|
||||
let centers = self.village_centers();
|
||||
let spot = self.raiding.siege.spot;
|
||||
let Some(at) = self.find_siege_spawn_pos(¢ers, spot) else {
|
||||
return;
|
||||
};
|
||||
let Some(id) = self.spawn_event_mob("minecraft:zombie", at, false, raider::Reason::Event)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let yaw = self.world.random.next_float() * 360.0;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
(m.yaw, m.previous_yaw, m.body_yaw, m.previous_body_yaw) = (yaw, yaw, yaw, yaw);
|
||||
(m.head_yaw, m.previous_head_yaw) = (yaw, yaw);
|
||||
}
|
||||
self.spawn_jockeys(id, false);
|
||||
}
|
||||
/// `VillageSiege.findRandomSpawnPos`: ten tries within 8 blocks of the
|
||||
/// spot for a village column where a zombie may spawn.
|
||||
fn find_siege_spawn_pos(
|
||||
&mut self,
|
||||
centers: &HashSet<(i32, i32, i32)>,
|
||||
spot: [i32; 3],
|
||||
) -> Option<[f64; 3]> {
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
let mut found = None;
|
||||
for _ in 0..10 {
|
||||
let x = spot[0] + random.next_int_bound(16).unwrap_or(0) - 8;
|
||||
let z = spot[2] + random.next_int_bound(16).unwrap_or(0) - 8;
|
||||
if !self.world.chunks.contains_key(&(x >> 4, z >> 4)) {
|
||||
continue;
|
||||
}
|
||||
let y = self.surface_height(x, z);
|
||||
if Self::is_village(centers, [x, y, z])
|
||||
&& spawn_rules::check_spawn_rules(
|
||||
Species::Zombie,
|
||||
&self.natural_level(),
|
||||
spawn_rules::Reason::Event,
|
||||
&mut random,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
)
|
||||
{
|
||||
found = Some([f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.world.random = random;
|
||||
found
|
||||
}
|
||||
/// A raider's ominous banner (drop chance 2.0: always dropped) and a
|
||||
/// patrol captain's ominous bottle (`entities/pillager`).
|
||||
/// `OminousBottleAmplifier.onConsume`: Bad Omen for 100 minutes at the
|
||||
|
||||
@@ -182,16 +182,23 @@ pub(super) struct Remote {
|
||||
online_mode: bool,
|
||||
/// Player entities in the level: entity id to profile UUID.
|
||||
level_players: HashMap<i32, u128>,
|
||||
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
|
||||
/// theirs in `Mob::glowing`.
|
||||
glowing: std::collections::HashSet<i32>,
|
||||
/// The local player's profile UUID (always in the level).
|
||||
profile: u128,
|
||||
/// The local player's profile name: its score holder and team member.
|
||||
pub(super) profile_name: String,
|
||||
destroying: Option<([i32; 3], u8, f32)>,
|
||||
/// `MultiPlayerGameMode.destroyingItem`: the held item key the break began with.
|
||||
destroying_item: String,
|
||||
destroy_delay: u32,
|
||||
input: horizon_client::interaction_input::InteractionInput,
|
||||
stop_mining: bool,
|
||||
placing: Option<([i32; 3], u64)>,
|
||||
death_message: Option<String>,
|
||||
death_message: Option<horizon_client::text::Text>,
|
||||
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
|
||||
show_death_screen: bool,
|
||||
player_type: Option<Arc<MobType>>,
|
||||
/// Inventory as last synchronised, for creative-mode slot updates.
|
||||
synced: Vec<Option<ItemStack>>,
|
||||
@@ -468,14 +475,17 @@ impl Game<'_> {
|
||||
listed: Default::default(),
|
||||
online_mode: false,
|
||||
level_players: HashMap::new(),
|
||||
glowing: Default::default(),
|
||||
profile: 0,
|
||||
profile_name: String::new(),
|
||||
destroying: None,
|
||||
destroying_item: String::new(),
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
stop_mining: false,
|
||||
placing: None,
|
||||
death_message: None,
|
||||
show_death_screen: true,
|
||||
player_type,
|
||||
synced: vec![None; 36],
|
||||
decode_errors: 0,
|
||||
@@ -666,10 +676,14 @@ impl Game<'_> {
|
||||
}
|
||||
let text = content.plain();
|
||||
log::info!("[chat] {text}");
|
||||
// addServerSystemMessage and addClientSystemMessage both carry
|
||||
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
|
||||
let indicator = (source != horizon_client::chat::ChatSource::Player)
|
||||
.then_some(horizon_client::chat::SYSTEM_INDICATOR);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
content,
|
||||
None,
|
||||
indicator,
|
||||
source,
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
@@ -995,6 +1009,12 @@ impl Game<'_> {
|
||||
r.predictions.clear();
|
||||
self.set_mode_id(spawn.game_type);
|
||||
self.dim = Dim::from_id(&spawn.dimension);
|
||||
// ClientPacketListener.handleLogin / handleRespawn: the server's
|
||||
// Player.getLastDeathLocation, for the recovery compass.
|
||||
self.level.last_death = spawn
|
||||
.last_death
|
||||
.as_ref()
|
||||
.map(|d| (d.dimension.clone(), d.pos));
|
||||
if !same {
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(NoTerrain),
|
||||
@@ -1408,6 +1428,17 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A thrown trident is an arrow: placed at once, flown on by its tick.
|
||||
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
|
||||
if path.is_some() {
|
||||
t.position = Vec3::new(position[0], position[1], position[2]);
|
||||
}
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
t.yaw = yaw;
|
||||
t.pitch = pitch;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
|
||||
// the server's position replaces its own at once.
|
||||
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
|
||||
@@ -1466,7 +1497,9 @@ impl Game<'_> {
|
||||
self.remove_remote_dragon(id);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_projectile(id as u32);
|
||||
self.persist.uuids.remove(&(id as u32));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.glowing.remove(&id);
|
||||
r.entities.remove(&id);
|
||||
r.lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
@@ -1609,6 +1642,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleUpdateAttributes` for a mob: its maximum
|
||||
/// health (the hearts drawn over its mount) takes the server's value, from
|
||||
/// `AttributeInstance.calculateValue`.
|
||||
fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
|
||||
let catalog = &self.motion.attributes;
|
||||
let Some(max_health) = catalog.id("minecraft:max_health") else {
|
||||
return;
|
||||
};
|
||||
let Some(s) = values
|
||||
.iter()
|
||||
.find(|s| s.attribute == max_health.registry_id())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let amounts = |op: i32| {
|
||||
s.modifiers
|
||||
.iter()
|
||||
.filter(move |m| m.operation == op)
|
||||
.map(|m| m.amount)
|
||||
};
|
||||
let base = s.base + amounts(0).sum::<f64>();
|
||||
let mut value = base + amounts(1).map(|a| base * a).sum::<f64>();
|
||||
for amount in amounts(2) {
|
||||
value *= 1.0 + amount;
|
||||
}
|
||||
let value = catalog.attribute(max_health).definition.sanitize(value);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MaxHealth,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_packet(&mut self, packet: C) -> Result<()> {
|
||||
match packet {
|
||||
// ClientCommonPacketListenerImpl.handlePing runs on the game
|
||||
@@ -1625,6 +1693,7 @@ impl Game<'_> {
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
r.view_distance = p.chunk_radius;
|
||||
r.show_death_screen = p.show_death_screen;
|
||||
}
|
||||
self.streaming.radius =
|
||||
self.settings.render_distance.min(p.chunk_radius + 1).max(2);
|
||||
@@ -1777,6 +1846,11 @@ impl Game<'_> {
|
||||
r.level_players.remove(&p.id);
|
||||
}
|
||||
}
|
||||
// Entity.getTeam: a mob is on a team by its UUID.
|
||||
self.persist.uuids.insert(
|
||||
p.id as u32,
|
||||
horizon_client::lan::playerdata::uuid_ints(p.uuid),
|
||||
);
|
||||
self.spawn_entity(p.id, &kind, p.position, rotation);
|
||||
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
@@ -1895,7 +1969,13 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
(0, Value::Byte(flags)) => m.invisible = flags & 0x20 != 0,
|
||||
(0, Value::Byte(flags)) => {
|
||||
// An invisible illusioner draws its mirror
|
||||
// images instead (`illusioner::set_invisible`).
|
||||
m.invisible = flags & 0x20 != 0
|
||||
&& m.species() != horizon_client::mobs::Species::Illusioner;
|
||||
m.glowing = flags & 0x40 != 0;
|
||||
}
|
||||
(2, Value::OptText(name)) => {
|
||||
let text = name.as_ref().map(horizon_client::entity_nbt::name_text);
|
||||
m.custom_name =
|
||||
@@ -1912,6 +1992,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, _, Value::Byte(flags))) = p.values.iter().find(|v| v.0 == 0)
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
if flags & 0x40 != 0 {
|
||||
r.glowing.insert(p.id);
|
||||
} else {
|
||||
r.glowing.remove(&p.id);
|
||||
}
|
||||
}
|
||||
self.remote_mob_data(p.id, &p.values);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Creeper
|
||||
@@ -1926,6 +2015,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
|
||||
(7, Value::Int(ticks)) => m.ticks_frozen = *ticks,
|
||||
// Strider.DATA_SUFFOCATING (after AgeableMob's two
|
||||
// and DATA_BOOST_TIME): a cold strider shakes.
|
||||
(19, Value::Bool(cold))
|
||||
if m.species() == horizon_client::mobs::Species::Strider =>
|
||||
{
|
||||
m.extra.flag = *cold;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
|
||||
// too, remote_mob_data's apply reads index 8 above.
|
||||
let mannequin = self
|
||||
@@ -1946,6 +2051,13 @@ impl Game<'_> {
|
||||
m.extra.ints[1] = code;
|
||||
}
|
||||
}
|
||||
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
|
||||
// right): the arm a swing of the main hand moves.
|
||||
if let (15, Value::Id(arm)) = (index, value)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
m.extra.main_hand_left = *arm == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
|
||||
@@ -2024,13 +2136,38 @@ impl Game<'_> {
|
||||
// player holds and wears (EquipmentSlot ordinals).
|
||||
C::SetEquipment(p) => {
|
||||
self.remote_object_equipment(p.id, &p.slots);
|
||||
let items: Vec<(u8, Option<Arc<str>>)> = p
|
||||
let items: Vec<(u8, Option<Arc<str>>, Option<ItemStack>)> = p
|
||||
.slots
|
||||
.iter()
|
||||
.map(|(slot, stack)| (*slot, stack.as_ref().map(|s| self.item_name(s.item))))
|
||||
.map(|(slot, stack)| {
|
||||
(
|
||||
*slot,
|
||||
stack.as_ref().map(|s| self.item_name(s.item)),
|
||||
self.to_stack(stack),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// CrossbowItem.isCharged: the main hand's `charged_projectiles`
|
||||
// (AvatarRenderer.getArmPose holds a loaded crossbow up).
|
||||
let charged = p.slots.iter().find(|(slot, _)| *slot == 0).map(|(_, s)| {
|
||||
s.as_ref().is_some_and(|s| {
|
||||
matches!(
|
||||
s.component(horizon_protocol::item::component::CHARGED_PROJECTILES),
|
||||
Some(horizon_protocol::item::Component::Stacks(list))
|
||||
if !list.is_empty()
|
||||
)
|
||||
})
|
||||
});
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
for (slot, item) in items {
|
||||
if let Some(charged) = charged
|
||||
&& &*m.kind.id == "minecraft:player"
|
||||
{
|
||||
m.extra.charged = charged;
|
||||
}
|
||||
// The body slot (6: horse armour, llama carpet, harness)
|
||||
// is kept in `head`, as Horizon's own mobs do.
|
||||
let body = horizon_client::mobs::drops::head_is_body(m.species());
|
||||
for (slot, item, stack) in items {
|
||||
let e = &mut m.extra;
|
||||
match slot {
|
||||
0 => e.main_hand = item,
|
||||
@@ -2038,7 +2175,13 @@ impl Game<'_> {
|
||||
2 => e.feet = item,
|
||||
3 => e.legs = item,
|
||||
4 => e.chest = item,
|
||||
5 => e.head = item,
|
||||
5 if !body => e.head = item,
|
||||
6 if body => {
|
||||
// A wolf's armor shows its cracks.
|
||||
e.body_damage = stack.as_ref().map_or(0, |s| s.damage as i32);
|
||||
e.head = item;
|
||||
}
|
||||
7 => m.set_saddle(stack),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -2123,6 +2266,10 @@ impl Game<'_> {
|
||||
self.remote_firework_explosion(p.id);
|
||||
}
|
||||
self.remote_fangs_event(p.id as u32, p.event);
|
||||
// LivingEntity.handleEntityEvent 2: a spear's hit felt.
|
||||
if p.event == 2 {
|
||||
self.remote_kinetic_hit(p.id);
|
||||
}
|
||||
// LocalPlayer.setPermissions also refreshes an open World Options screen.
|
||||
if (24..=28).contains(&p.event)
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
@@ -2130,6 +2277,14 @@ impl Game<'_> {
|
||||
self.ui.registries.permission = (p.event - 24) as u8;
|
||||
self.remote_world_options_permission(p.event >= 26);
|
||||
}
|
||||
// Ravager.handleEntityEvent 69: the roar's puff and push.
|
||||
if p.event == 69 {
|
||||
self.remote_ravager_roar(p.id);
|
||||
}
|
||||
// ClientPacketListener.handleEntityEvent 21: a guardian's hum.
|
||||
if p.event == 21 {
|
||||
self.start_guardian_sound(p.id as u32);
|
||||
}
|
||||
if p.event == 1
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
@@ -2147,6 +2302,10 @@ impl Game<'_> {
|
||||
{
|
||||
m.health = 0.0;
|
||||
}
|
||||
// The events that start a mob's animation (the warden's).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
horizon_client::mobs::sync::entity_event(m, p.event as u8);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
|
||||
if p.event == 60
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
|
||||
@@ -2157,6 +2316,9 @@ impl Game<'_> {
|
||||
if p.event == super::totem::TOTEM_EVENT {
|
||||
self.remote_totem(p.id);
|
||||
}
|
||||
if p.event == super::combat::DROWN_EVENT {
|
||||
self.remote_drown(p.id);
|
||||
}
|
||||
// Entity event 53: the honey slide particles of a player
|
||||
// sliding down a honey block (HoneyBlock.showSlideParticles).
|
||||
if p.event == 53 {
|
||||
@@ -2187,7 +2349,7 @@ impl Game<'_> {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
self.swing_hand(hand, animation);
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.swing();
|
||||
m.swing_hand_modified(hand == Hand::Off, animation.duration);
|
||||
}
|
||||
}
|
||||
// The player's own instances: its maximum health (the hearts the
|
||||
@@ -2199,6 +2361,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_remote_health();
|
||||
}
|
||||
C::UpdateAttributes(p) => self.remote_mob_attributes(p.id, &p.values),
|
||||
C::UpdateMobEffect(p) => self.remote_effect(&p),
|
||||
C::RemoveMobEffect(p) => {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
@@ -2223,9 +2386,14 @@ impl Game<'_> {
|
||||
pl.health.flash_health_change(p.health);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
|
||||
// The death screen opens (or the respawn is asked for) with the
|
||||
// death packet when the rule hides it.
|
||||
let shown = self.remote.as_ref().is_none_or(|r| r.show_death_screen);
|
||||
if p.health <= 0.0 && shown && self.screen != Screen::Dead && self.player.is_some()
|
||||
{
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.ui.death_confirm = None;
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
@@ -2233,17 +2401,23 @@ impl Game<'_> {
|
||||
// The component resolves in the client's current language, as
|
||||
// the chat line does, not in the language the session joined
|
||||
// with (`Remote.lang` is the vanilla server-language table).
|
||||
let message = remote_text(&p.message).plain();
|
||||
let message = remote_text(&p.message);
|
||||
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = Some(message);
|
||||
}
|
||||
// ClientPacketListener.handlePlayerCombatKill: the death
|
||||
// screen, whatever health was last sent.
|
||||
// screen, whatever health was last sent, or `respawn()` at
|
||||
// once when it is hidden.
|
||||
if me && self.screen != Screen::Dead && self.player.is_some() {
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.capture(false);
|
||||
if self.remote.as_ref().is_none_or(|r| r.show_death_screen) {
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.ui.death_confirm = None;
|
||||
self.capture(false);
|
||||
} else {
|
||||
self.respawn_player()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
|
||||
@@ -2319,6 +2493,12 @@ impl Game<'_> {
|
||||
r.game_mode = mode;
|
||||
}
|
||||
}
|
||||
// IMMEDIATE_RESPAWN: LocalPlayer.setShowDeathScreen.
|
||||
11 => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.show_death_screen = p.param == 0.0;
|
||||
}
|
||||
}
|
||||
7 => {
|
||||
self.world.weather.rain_level = (p.param, p.param);
|
||||
self.world.weather.raining = p.param > 0.0;
|
||||
@@ -2358,6 +2538,20 @@ impl Game<'_> {
|
||||
r.state_id = p.state;
|
||||
}
|
||||
let stack = self.to_stack(&p.item);
|
||||
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
|
||||
// gains items squeezes for 5 ticks.
|
||||
if p.container == 0
|
||||
&& (36..=45).contains(&p.slot)
|
||||
&& let Some(new) = &stack
|
||||
{
|
||||
let (pop, old) = match p.slot {
|
||||
45 => (9, self.offhand.as_ref()),
|
||||
s => (s as usize - 36, self.inventory[s as usize - 36].as_ref()),
|
||||
};
|
||||
if old.is_none_or(|old| old.count < new.count) {
|
||||
self.hotbar_pop[pop] = 5;
|
||||
}
|
||||
}
|
||||
self.set_menu_slot(p.container, i32::from(p.slot), stack);
|
||||
self.remote_synced();
|
||||
}
|
||||
@@ -3021,6 +3215,7 @@ impl Game<'_> {
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
thrower: None,
|
||||
target: None,
|
||||
id: id as u32,
|
||||
stack,
|
||||
position: at,
|
||||
@@ -3057,6 +3252,8 @@ impl Game<'_> {
|
||||
p.velocity = v;
|
||||
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
|
||||
b.velocity = v;
|
||||
} else if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
|
||||
t.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
@@ -3177,6 +3374,10 @@ impl Game<'_> {
|
||||
// server says the entry ended. `onCooldownEnded` is the plain
|
||||
// ItemCooldowns' no-op on a client.
|
||||
self.cooldowns.tick();
|
||||
// Inventory.tick's ItemStack.inventoryTick: the pop time runs down.
|
||||
for pop in &mut self.hotbar_pop {
|
||||
*pop = pop.saturating_sub(1);
|
||||
}
|
||||
// Leaving a slot clears its bundle's selection here and on the server.
|
||||
self.tick_bundle_hover();
|
||||
// Options.broadcastOptions: a changed render distance or Chat option
|
||||
@@ -3216,6 +3417,7 @@ impl Game<'_> {
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
self.tick_remote_entities(running);
|
||||
self.tick_remote_guardian_eyes(running);
|
||||
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
|
||||
// and the moving pistons a block event's client-side moveBlocks made
|
||||
// (their progress advances between the server's block updates).
|
||||
@@ -3348,13 +3550,12 @@ impl Game<'_> {
|
||||
let events = std::mem::take(&mut player.events);
|
||||
self.previous_eye = self.eye;
|
||||
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
|
||||
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
for event in events {
|
||||
match event {
|
||||
horizon_client::motion::MotionEvent::Emission {
|
||||
state, in_water, ..
|
||||
} if !crouching => {
|
||||
} => {
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Player.playStepSound: muffled under the swim sound in water.
|
||||
let (volume, pitch) = if in_water {
|
||||
@@ -3368,6 +3569,14 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
|
||||
}
|
||||
horizon_client::motion::MotionEvent::Chime { volume, pitch } => {
|
||||
self.audio.play(
|
||||
"minecraft:block.amethyst_block.chime",
|
||||
Some(feet),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
// NetherPortalBlock.entityInside on the client's own level:
|
||||
// the portal overlay and its trigger sound (the server moves
|
||||
// the player).
|
||||
@@ -3510,6 +3719,43 @@ impl Game<'_> {
|
||||
}
|
||||
/// ClientLevel.tickEntities; a frozen level only ticks the players
|
||||
/// (`TickRateManager.isEntityFrozen`).
|
||||
/// `GuardianRenderer.getEntityToLookAt`: a guardian's eye follows its
|
||||
/// synched attack target, else the camera.
|
||||
fn tick_remote_guardian_eyes(&mut self, running: bool) {
|
||||
use horizon_client::mobs::{Species, Who, guardian};
|
||||
if !running {
|
||||
return;
|
||||
}
|
||||
let camera = self.player.as_ref().map(|p| {
|
||||
[
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.eye_height),
|
||||
p.position.z,
|
||||
]
|
||||
});
|
||||
let looks: Vec<_> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| {
|
||||
matches!(m.species(), Species::Guardian | Species::ElderGuardian) && m.alive()
|
||||
})
|
||||
.filter_map(|(i, m)| {
|
||||
let look = match m.ai.target.filter(|_| m.extra.flag) {
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| [t.position.x, t.eye_y(), t.position.z]),
|
||||
_ => camera,
|
||||
};
|
||||
look.map(|look| (i, look))
|
||||
})
|
||||
.collect();
|
||||
for (i, look) in looks {
|
||||
guardian::look_at(&mut self.mobs[i], look);
|
||||
}
|
||||
}
|
||||
fn tick_remote_entities(&mut self, running: bool) {
|
||||
if running {
|
||||
self.tick_text_displays();
|
||||
@@ -3518,22 +3764,53 @@ impl Game<'_> {
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
// A frozen level ticks only the players, as below.
|
||||
let fluids: Vec<_> = {
|
||||
let (fluids, thundering): (Vec<_>, bool) = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let players = self.remote.as_ref().map(|r| &r.level_players);
|
||||
self.mobs
|
||||
let fluids = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
|
||||
.then(|| w.fluids(m.bounds(), m.eye_y()))
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(fluids, w.thundering)
|
||||
};
|
||||
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
|
||||
if let Some(fluids) = fluids {
|
||||
m.fluids = fluids;
|
||||
}
|
||||
}
|
||||
let game_time = self.world.game_time;
|
||||
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
|
||||
let nether = self.dim == super::Dim::Nether;
|
||||
// What a wither's side heads can look at, by network entity id: the
|
||||
// mobs and the player (position and eye height).
|
||||
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.species() == horizon_client::mobs::Species::Wither)
|
||||
{
|
||||
let own = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.zip(self.eye_position())
|
||||
.map(|(r, eye)| (r.entity_id, eye, eye[1]));
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(
|
||||
m.id as i32,
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.eye_y(),
|
||||
)
|
||||
})
|
||||
.chain(own)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3542,6 +3819,7 @@ impl Game<'_> {
|
||||
let head_lerps = &mut r.head_lerps;
|
||||
let players = &r.level_players;
|
||||
let mut using = Vec::new();
|
||||
let mut mob_fx = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
let player = players.contains_key(&(m.id as i32));
|
||||
if !running && !player {
|
||||
@@ -3581,6 +3859,17 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::PolarBear {
|
||||
horizon_client::mobs::polar_bear::client_tick(m);
|
||||
}
|
||||
// The particles these mobs' own ticks draw on the client side.
|
||||
{
|
||||
use horizon_client::mobs::{Species as S, cube, illusioner, nautilus, wither};
|
||||
match m.species() {
|
||||
S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx),
|
||||
S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx),
|
||||
S::Illusioner => illusioner::client_tick(m, &mut mob_fx),
|
||||
S::Wither => wither::client_smoke(m, &mut mob_fx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if m.hurt_time > 0 {
|
||||
m.hurt_time -= 1;
|
||||
}
|
||||
@@ -3604,6 +3893,15 @@ impl Game<'_> {
|
||||
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
|
||||
*steps -= 1;
|
||||
}
|
||||
// Fox.tick: the head tilt and crouch follow the synched flags.
|
||||
if m.species() == horizon_client::mobs::Species::Fox {
|
||||
horizon_client::mobs::fox::client_tick(m);
|
||||
}
|
||||
// Cat.tick: the lying and relaxing amounts follow the synched flags.
|
||||
if m.species() == horizon_client::mobs::Species::Cat {
|
||||
m.anim.vo = m.anim.v;
|
||||
horizon_client::mobs::tame::cat_amounts(m);
|
||||
}
|
||||
// Chicken.aiStep: the wings follow the server's on-ground flag.
|
||||
if m.species() == horizon_client::mobs::Species::Chicken {
|
||||
horizon_client::mobs::farm::chicken_flap(m);
|
||||
@@ -3611,6 +3909,20 @@ impl Game<'_> {
|
||||
// LivingEntity.updatingUsingItem: the ticks the item has been
|
||||
// used before this one. Another player's use particles; a mob's
|
||||
// drawn bow only needs the count (its pull follows it).
|
||||
if player {
|
||||
// AvatarRenderer.getArmPose: the used item's BLOCK animation
|
||||
// (an item with `blocks_attacks`) raises the arm.
|
||||
let used = if m.extra.using_off_hand {
|
||||
&m.extra.off_hand
|
||||
} else {
|
||||
&m.extra.main_hand
|
||||
};
|
||||
m.extra.blocking = m.extra.using_item > 0
|
||||
&& used
|
||||
.as_ref()
|
||||
.and_then(|i| self.res.items().get(i))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
}
|
||||
if m.extra.using_item > 0 {
|
||||
if player {
|
||||
let item = if m.extra.using_off_hand {
|
||||
@@ -3619,7 +3931,7 @@ impl Game<'_> {
|
||||
m.extra.main_hand.clone()
|
||||
};
|
||||
if let Some(item) = item {
|
||||
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
|
||||
let eye = m.eye_height();
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
using.push((
|
||||
item,
|
||||
@@ -3637,6 +3949,8 @@ impl Game<'_> {
|
||||
// a player's body following its walk and look.
|
||||
if player {
|
||||
remote_player_head_turn(m);
|
||||
// LivingEntity.tick: the glide count, wings and swim amount.
|
||||
m.tick_avatar_pose(m.fluids.in_water);
|
||||
} else if m.alive() {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
@@ -3653,6 +3967,29 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Bat {
|
||||
horizon_client::mobs::bat::setup_animation_states(m);
|
||||
}
|
||||
// The animation states a server computes in its own `ai_step`.
|
||||
horizon_client::mobs::sync::client_tick(m, game_time);
|
||||
if m.species() == horizon_client::mobs::Species::Panda {
|
||||
horizon_client::mobs::panda::client_tick(m, thundering);
|
||||
}
|
||||
match m.species() {
|
||||
// WitherBoss.aiStep: the side heads turn toward their targets.
|
||||
horizon_client::mobs::Species::Wither => {
|
||||
horizon_client::mobs::wither::client_tick(m, |id| {
|
||||
wither_targets
|
||||
.iter()
|
||||
.find(|t| t.0 == id)
|
||||
.map(|t| (t.1, t.2))
|
||||
});
|
||||
}
|
||||
// Hoglin and piglin `isConverting` outside the Nether.
|
||||
horizon_client::mobs::Species::Hoglin
|
||||
| horizon_client::mobs::Species::Piglin
|
||||
| horizon_client::mobs::Species::PiglinBrute => {
|
||||
horizon_client::mobs::piglin::client_converting(m, nether);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for o in self.orbs.iter_mut().filter(|_| running) {
|
||||
o.previous = o.position;
|
||||
@@ -3677,10 +4014,12 @@ impl Game<'_> {
|
||||
d.tick(world, facts, collision, false, &self.res.motion().fluids);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
let _ = self.handle_mob_events(mob_fx);
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
|
||||
}
|
||||
if running {
|
||||
self.guardian_bubbles();
|
||||
// The dragon: LivingEntity's interpolation, inside its aiStep.
|
||||
let dragon_lerp = self
|
||||
.dragon
|
||||
@@ -3842,6 +4181,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.stopDestroyBlock`: aborts the break and starts
|
||||
/// the attack strength again.
|
||||
pub(super) fn stop_remote_mining(&mut self) {
|
||||
if let Some((pos, _, _)) = self.remote.as_mut().and_then(|r| r.destroying.take()) {
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
@@ -3850,6 +4191,7 @@ impl Game<'_> {
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
self.reset_attack_strength();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3903,11 +4245,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.destroy_progress(hit.pos)
|
||||
};
|
||||
let same = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.destroying)
|
||||
.is_some_and(|(p, _, _)| p == hit.pos);
|
||||
// sameDestroyTarget: the same block and still the item the break began with.
|
||||
let held = self.main_hand_key();
|
||||
let same = self.remote.as_ref().is_some_and(|r| {
|
||||
r.destroying.is_some_and(|(p, _, _)| p == hit.pos) && r.destroying_item == held
|
||||
});
|
||||
if start && same {
|
||||
return;
|
||||
}
|
||||
@@ -3934,7 +4276,11 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.destroying = Some((hit.pos, face, 0.0));
|
||||
r.destroying_item = held;
|
||||
}
|
||||
// startDestroyBlock plays the first hit and crack itself; the
|
||||
// server's level events 2019 and 2020 do the rest.
|
||||
self.breaking_block_effects(hit.pos, hit.face, true);
|
||||
self.mining = Some(Mining {
|
||||
pos: hit.pos,
|
||||
progress: 0.0,
|
||||
@@ -3947,20 +4293,6 @@ impl Game<'_> {
|
||||
};
|
||||
let done = done + progress;
|
||||
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
|
||||
if ticks % 4 == 1 {
|
||||
let c = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
&facts.sounds.hit,
|
||||
Some(c),
|
||||
(facts.sounds.volume + 1.0) / 8.0,
|
||||
facts.sounds.pitch * 0.5,
|
||||
);
|
||||
}
|
||||
self.crack_particle(pos, hit.face, state);
|
||||
if done >= 1.0 {
|
||||
let seq = self.next_sequence();
|
||||
self.predict_break(pos, state, seq);
|
||||
@@ -4128,6 +4460,21 @@ impl Game<'_> {
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
} else if held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
|
||||
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
|
||||
// server brushes and the dust is this client's own.
|
||||
self.with_hand(hand, |g| {
|
||||
if g.using.is_none() {
|
||||
g.using = Some((g.held_slot(), 0));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
|
||||
// CampfireBlock.useItemOn succeeded: this hand swings and the
|
||||
// click ends, so a shield in the other hand is not raised.
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
} else if let Some(stack) = held.as_ref()
|
||||
&& self.predict_item_on_block(stack, &hit)
|
||||
{
|
||||
@@ -4295,7 +4642,7 @@ impl Game<'_> {
|
||||
/// predicted under the use's sequence until the server acknowledges it
|
||||
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
|
||||
/// server refused goes back.
|
||||
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
pub(super) fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
@@ -4383,7 +4730,10 @@ impl Game<'_> {
|
||||
if self.using.is_some() {
|
||||
return;
|
||||
}
|
||||
let bundle = self.res.items().get(&held.item).is_some_and(|i| i.bundle);
|
||||
let start = match &*held.item {
|
||||
// BundleItem.use: always starts; the server drops the contents.
|
||||
_ if bundle => true,
|
||||
// BowItem.use: with an arrow, or in creative.
|
||||
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
|
||||
// CrossbowItem.use: charging when not charged and with ammunition;
|
||||
@@ -4399,6 +4749,14 @@ impl Game<'_> {
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// InstrumentItem.use: the horn plays here too (`playSound(player, ...)`
|
||||
// skips the player's own client), the server's copy tells the others.
|
||||
"minecraft:goat_horn" => {
|
||||
self.remote_use_instrument(held);
|
||||
return;
|
||||
}
|
||||
// Item.use: a kinetic weapon starts being used.
|
||||
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
|
||||
// ShieldItem.use: anything that blocks attacks, off cooldown.
|
||||
_ => {
|
||||
self.raise_shield(self.held_slot());
|
||||
@@ -4409,13 +4767,35 @@ impl Game<'_> {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
}
|
||||
/// `InstrumentItem.use` on this client: unless the horn is on cooldown,
|
||||
/// it is held up, plays its sound at the player and starts its cooldown.
|
||||
fn remote_use_instrument(&mut self, held: &ItemStack) {
|
||||
let Some(instrument) = self.res.items().instrument(held) else {
|
||||
return;
|
||||
};
|
||||
let group = horizon_client::cooldowns::group(held, None);
|
||||
if self.cooldowns.is_on_cooldown(&group) {
|
||||
return;
|
||||
}
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
|
||||
if instrument.use_duration > 0.0 {
|
||||
self.add_cooldown(group, instrument.use_ticks());
|
||||
}
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
|
||||
fn held_until_released(&self, stack: &ItemStack) -> bool {
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
|
||||
) || self.blocks_attacks(stack).is_some()
|
||||
) || horizon_client::spear::Kinetic::of(&stack.item).is_some()
|
||||
|| self.blocks_attacks(stack).is_some()
|
||||
}
|
||||
|
||||
/// The rest of Minecraft.startUseItem once the main hand's target passed:
|
||||
@@ -4448,12 +4828,53 @@ impl Game<'_> {
|
||||
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
|
||||
// different stack in the hand. The off hand keeps its item.
|
||||
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
|
||||
// BrushItem.onUseTick: with no block aimed the use ends (the server
|
||||
// ends it too); a stroke shows its dust. Its sounds are the server's.
|
||||
if stack
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:brush")
|
||||
{
|
||||
let elapsed = ticks + 1;
|
||||
let hit = self.pick().filter(|_| in_hand);
|
||||
if let Some(hit) = &hit
|
||||
&& elapsed % 10 == 5
|
||||
{
|
||||
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
|
||||
}
|
||||
if hit.is_none() && in_hand {
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 6,
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
}
|
||||
self.using = hit.and(Some((slot, elapsed))).filter(|_| elapsed < 200);
|
||||
return;
|
||||
}
|
||||
// BundleItem.getUseDuration: 200 ticks, the server drops the contents.
|
||||
if stack
|
||||
.as_ref()
|
||||
.is_some_and(|s| self.res.items().get(&s.item).is_some_and(|i| i.bundle))
|
||||
{
|
||||
self.using = (in_hand && ticks + 1 < 200).then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
// A drawn bow, a charging crossbow, a raised shield...: held until
|
||||
// the button lets go.
|
||||
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
|
||||
self.using = in_hand.then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
// A goat horn is held up for its instrument's use duration.
|
||||
if let Some(duration) = stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().instrument(s))
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
self.using = (in_hand && ticks + 1 < duration).then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
let consumable = stack
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
@@ -5230,10 +5651,23 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Dead
|
||||
&& let Some(message) = &r.death_message
|
||||
{
|
||||
let tw = HudBatch::text_width(atlas, message);
|
||||
batch.text(atlas, message, (w - tw) / 2.0, 85.0, u32::MAX, true);
|
||||
// DeathScreen draws the styled cause component.
|
||||
let tw = atlas.font.width_text(message);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
message,
|
||||
((w - tw) / 2.0).floor(),
|
||||
85.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Shared flag 6 of an entity on a server's client (`None` without a server).
|
||||
pub(super) fn remote_glowing(&self, id: u32) -> Option<bool> {
|
||||
let r = self.remote.as_ref()?;
|
||||
Some(r.glowing.contains(&(id as i32)))
|
||||
}
|
||||
/// Status for `horizon-ctl status`.
|
||||
/// The other players' entities in the level and their names (from the
|
||||
/// player list).
|
||||
@@ -5521,7 +5955,8 @@ fn hand_id(hand: Hand) -> i32 {
|
||||
/// server decides; this is the client's guess of whether to go on.
|
||||
fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
|
||||
let id = info.id.trim_start_matches("minecraft:");
|
||||
info.consumable.is_some()
|
||||
id.ends_with("_spear")
|
||||
|| info.consumable.is_some()
|
||||
|| info.equip_slot.is_some()
|
||||
|| info.blocks_attacks.is_some()
|
||||
|| horizon_client::items::bucket_contents(&info.id).is_some()
|
||||
@@ -5600,7 +6035,16 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
self.0.remote_use();
|
||||
}
|
||||
fn pick(&mut self) {
|
||||
if let Some(hit) = self.0.pick() {
|
||||
// Minecraft.pickBlockOrEntity: an entity before the block is asked
|
||||
// for (`handlePickItemFromEntity`), else the block.
|
||||
if let Some(entity) = self.0.pick_entity_hit() {
|
||||
if let Some(id) = entity {
|
||||
self.0.send(S::PickItemFromEntity(play::PickItemFromEntity {
|
||||
id,
|
||||
include_data: self.0.has_control_down(),
|
||||
}));
|
||||
}
|
||||
} else if let Some(hit) = self.0.pick() {
|
||||
self.0.send(S::PickItemFromBlock(play::PickItemFromBlock {
|
||||
pos: hit.pos,
|
||||
include_data: self.0.has_control_down(),
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, Who, bat, bee, finalize, tame};
|
||||
use horizon_client::mobs::{
|
||||
Mob, Species, Who, armadillo, bat, bee, camel, creaking, equine, finalize, illusioner, panda,
|
||||
sniffer, sync, tame, zombie_villager,
|
||||
};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
|
||||
use horizon_protocol::registry::{Builtin, Dynamic};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
@@ -15,12 +19,37 @@ use horizon_protocol::registry::{Builtin, Dynamic};
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
if &*m.kind.id == "minecraft:player" {
|
||||
player_poses(m, values);
|
||||
}
|
||||
for (index, _, value) in values {
|
||||
match (*index, value) {
|
||||
// Entity.DATA_POSE: a player's or mannequin's box follows it.
|
||||
(6, Value::Id(pose))
|
||||
if matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin") =>
|
||||
{
|
||||
m.extra.avatar_pose = horizon_client::pose::Pose::from_id(*pose);
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
|
||||
// client, which draws the flames. A client doesn't count the
|
||||
// fire down: the flag goes off when the server's fire is out.
|
||||
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
|
||||
(0, Value::Byte(flags)) => {
|
||||
m.fire_ticks = i32::from(flags & 1);
|
||||
// Drowned.setSwimming: the shared flag of swimming.
|
||||
if species == Species::Drowned {
|
||||
m.extra.flag = flags & 16 != 0;
|
||||
}
|
||||
// The shared flag of invisibility: Entity.isInvisible hides
|
||||
// the model (an illusioner shows its mirror images).
|
||||
let invisible = flags & 32 != 0;
|
||||
if species == Species::Illusioner {
|
||||
illusioner::set_invisible(m, invisible);
|
||||
} else if &*m.kind.id != "minecraft:player" {
|
||||
m.invisible = invisible;
|
||||
}
|
||||
}
|
||||
// Entity.DATA_POSE: the pose the species animates by.
|
||||
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
|
||||
// off hand 2. The flag starts and stops the use; the client ticks
|
||||
// the count itself (a bow's pull follows it).
|
||||
@@ -64,10 +93,23 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
Who::Mob(*target as u32)
|
||||
});
|
||||
}
|
||||
// Panda: the counters, the two genes and DATA_ID_FLAGS (after
|
||||
// AgeableMob's two entries).
|
||||
(Species::Panda, 18, Value::Int(n)) => panda::set_unhappy(m, *n),
|
||||
(Species::Panda, 19, Value::Int(n)) => panda::set_sneeze(m, *n),
|
||||
(Species::Panda, 20, Value::Int(n)) => panda::set_eat(m, *n),
|
||||
(Species::Panda, 21, Value::Byte(gene)) => m.extra.variant = (*gene).clamp(0, 6) as u8,
|
||||
(Species::Panda, 22, Value::Byte(gene)) => m.extra.variant2 = (*gene).clamp(0, 6) as u8,
|
||||
(Species::Panda, 23, Value::Byte(flags)) => panda::set_flags(m, *flags as u8),
|
||||
// PolarBear.DATA_STANDING_ID: the client animates the rearing.
|
||||
(Species::PolarBear, 18, Value::Bool(standing)) => m.extra.flag = *standing,
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0.
|
||||
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
|
||||
// Fox.DATA_TYPE_ID and DATA_FLAGS_ID.
|
||||
(Species::Fox, 18, Value::Int(variant)) => {
|
||||
m.extra.variant = (*variant).clamp(0, 1) as u8
|
||||
}
|
||||
(Species::Fox, 19, Value::Byte(flags)) => m.extra.ints[0] = i32::from(*flags as u8),
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
|
||||
(Species::Sheep, 18, Value::Byte(wool)) => {
|
||||
m.extra.color = (*wool & 15) as u8;
|
||||
@@ -86,6 +128,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
m.extra.sitting = flags & 1 != 0;
|
||||
m.extra.tame = flags & 4 != 0;
|
||||
}
|
||||
// Cat.IS_LYING and RELAX_STATE_ONE.
|
||||
(Species::Cat, 21, Value::Bool(lying)) => m.extra.flag = *lying,
|
||||
(Species::Cat, 22, Value::Bool(relaxing)) => m.extra.relax_one = *relaxing,
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's.
|
||||
(Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => {
|
||||
m.extra.color = (*c & 15) as u8;
|
||||
@@ -94,6 +139,55 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
|
||||
(Species::Warden, 16, Value::Int(anger)) => {
|
||||
if let Some(st) = m.extra.warden.as_mut() {
|
||||
st.client_anger = *anger;
|
||||
}
|
||||
}
|
||||
// Sniffer.DATA_STATE (after AgeableMob's two): the animation
|
||||
// follows it in `sync::client_tick`.
|
||||
(Species::Sniffer, 18, Value::Id(state)) => {
|
||||
sniffer::set_state(m, (*state).clamp(0, 6) as u8);
|
||||
}
|
||||
// Armadillo.ARMADILLO_STATE (after AgeableMob's two): the shell and
|
||||
// its animations follow it in `sync::client_tick`.
|
||||
(Species::Armadillo, 18, Value::Id(state)) => {
|
||||
armadillo::set_state(m, (*state).clamp(0, 3) as u8);
|
||||
}
|
||||
// Goat.DATA_HAS_LEFT_HORN and DATA_HAS_RIGHT_HORN: the model hides
|
||||
// a horn the goat lost.
|
||||
(Species::Goat, 19, Value::Bool(has)) => m.extra.lost_horns[0] = !*has,
|
||||
(Species::Goat, 20, Value::Bool(has)) => m.extra.lost_horns[1] = !*has,
|
||||
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID.
|
||||
(Species::Shulker, 16, Value::Id(face)) => m.extra.variant3 = (*face).clamp(0, 5) as u8,
|
||||
(Species::Shulker, 17, Value::Byte(peek)) => m.extra.counter = i32::from(*peek),
|
||||
(Species::Shulker, 18, Value::Byte(color)) => m.extra.color = *color as u8,
|
||||
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's
|
||||
// flags).
|
||||
(Species::Camel | Species::CamelHusk, 19, Value::Bool(dashing)) => {
|
||||
camel::set_dashing(m, *dashing);
|
||||
}
|
||||
(Species::Camel | Species::CamelHusk, 20, Value::Long(tick)) => {
|
||||
camel::set_synced_pose_tick(m, *tick);
|
||||
}
|
||||
// Vex.DATA_FLAGS_ID: charging in bit 0.
|
||||
(Species::Vex, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
|
||||
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
|
||||
(Species::Creaking, 16, Value::Bool(can_move)) => m.extra.flag2 = !*can_move,
|
||||
(Species::Creaking, 17, Value::Bool(active)) => m.extra.flag = *active,
|
||||
(Species::Creaking, 18, Value::Bool(true)) => creaking::set_tearing_down(m),
|
||||
// Allay.DATA_DANCING and DATA_CAN_DUPLICATE (a cooldown running
|
||||
// is any non-zero `egg_time`).
|
||||
(Species::Allay, 16, Value::Bool(dancing)) => m.extra.flag = *dancing,
|
||||
(Species::Allay, 17, Value::Bool(can)) => m.extra.egg_time = i32::from(!*can),
|
||||
// Axolotl.DATA_PLAYING_DEAD (after DATA_VARIANT): the factors
|
||||
// ease in `sync::client_tick`.
|
||||
(Species::Axolotl, 19, Value::Bool(dead)) => m.extra.axolotl.playing_dead = *dead,
|
||||
// CopperGolem.COPPER_GOLEM_STATE (after DATA_WEATHER_STATE).
|
||||
(Species::CopperGolem, 17, Value::Id(state)) => {
|
||||
m.extra.variant3 = (*state).clamp(0, 4) as u8;
|
||||
}
|
||||
// AbstractHorse's flags take 18. Horse.DATA_ID_TYPE_VARIANT: the
|
||||
// colour in the low byte, the markings in the next.
|
||||
(Species::Horse, 19, Value::Int(v)) => {
|
||||
@@ -137,6 +231,45 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
(Species::CopperGolem, 16, Value::Id(state)) => {
|
||||
m.extra.variant = (*state).clamp(0, 3) as u8;
|
||||
}
|
||||
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
|
||||
// and raised head of an angry enderman.
|
||||
(Species::Enderman, 17, Value::Bool(creepy)) => m.extra.flag = *creepy,
|
||||
// AbstractHorse.DATA_ID_FLAGS: eating 16 and standing 32 (the
|
||||
// graze and the rear), which `sync::client_tick` animates.
|
||||
(s, 18, Value::Byte(flags)) if equine::grazes(s) => equine::set_flags(m, *flags as u8),
|
||||
// Enderman.DATA_CARRY_STATE (0 for none): the block it holds.
|
||||
(Species::Enderman, 16, Value::Id(state)) => {
|
||||
m.extra.carried = (*state > 0).then_some(BlockStateId(*state as u32));
|
||||
}
|
||||
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
|
||||
// DATA_SPECIAL_TYPE_ID): the conversion's shaking.
|
||||
(Species::Zombie | Species::Husk, 18, Value::Bool(converting)) => {
|
||||
m.extra.counter2 = i32::from(*converting);
|
||||
}
|
||||
// ZombieVillager.DATA_CONVERTING_ID (before its villager data).
|
||||
(Species::ZombieVillager, 19, Value::Bool(converting)) => {
|
||||
zombie_villager::set_converting(m, *converting);
|
||||
}
|
||||
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two):
|
||||
// the head shake of `no`, which the server counts down.
|
||||
(Species::Villager | Species::WanderingTrader, 18, Value::Int(ticks)) => {
|
||||
m.extra.ints[0] = *ticks;
|
||||
}
|
||||
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
|
||||
// AbstractPiglin's: `sync`'s conversion shake reads it.
|
||||
(Species::Hoglin, 18, Value::Bool(immune))
|
||||
| (Species::Piglin | Species::PiglinBrute, 16, Value::Bool(immune)) => {
|
||||
m.extra.flag2 = *immune;
|
||||
}
|
||||
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
|
||||
(Species::Blaze, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
|
||||
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
|
||||
(Species::Turtle, 18, Value::Bool(has_egg)) => m.extra.flag = *has_egg,
|
||||
(Species::Turtle, 19, Value::Bool(laying)) => m.extra.flag2 = *laying,
|
||||
// WitherBoss.DATA_TARGET_B and _C (after DATA_TARGET_A): the
|
||||
// entities the side heads turn toward in `wither::client_tick`.
|
||||
(Species::Wither, 17, Value::Int(id)) => m.extra.ints[0] = *id,
|
||||
(Species::Wither, 18, Value::Int(id)) => m.extra.ints[1] = *id,
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
@@ -208,6 +341,21 @@ fn apply_registry_data(
|
||||
}
|
||||
}
|
||||
|
||||
/// What another player's pose draws from: gliding (Entity.isFallFlying, flag
|
||||
/// 0x80 of the shared flags), the pose (swimming), Riptide's spin
|
||||
/// (LivingEntity.isAutoSpinAttack, flag 4) and a loaded crossbow in the
|
||||
/// hand (the held stack comes with SetEquipment).
|
||||
fn player_poses(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
for (index, _, value) in values {
|
||||
match (*index, value) {
|
||||
(0, Value::Byte(flags)) => m.extra.fall_flying = flags & -128 != 0,
|
||||
(6, Value::Id(pose)) => m.extra.synced_pose = (*pose).clamp(0, 17) as u8,
|
||||
(8, Value::Byte(flags)) => m.extra.auto_spin_attack = flags & 4 != 0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The options of a synched particle list: an entity effect carries its ARGB
|
||||
/// colour, the omens none.
|
||||
fn swirls(list: &[Particle], builtin: &Builtin) -> Vec<ParticleOptions> {
|
||||
@@ -260,9 +408,20 @@ impl Game<'_> {
|
||||
let Some(r) = &self.remote else {
|
||||
return;
|
||||
};
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): a villager lies
|
||||
// in its bed the way the bed faces, or gets up.
|
||||
let bed = values.iter().find_map(|v| match v {
|
||||
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
apply_registry_data(m, values, &r.registries, self.world.game_time);
|
||||
if let Some(code) = bed
|
||||
&& m.species() == Species::Villager
|
||||
{
|
||||
m.extra.ints[1] = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Particles a client of a server draws for a mob's entity event, which the
|
||||
//! server does not send as a particle packet.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
mobs::Species,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Ravager.handleEntityEvent(69)`: forty puffs of smoke from the middle
|
||||
/// of its box, then `applyRoarKnockbackClient`: this client's own player
|
||||
/// within 4 blocks is thrown away from it.
|
||||
pub(super) fn remote_ravager_roar(&mut self, id: i32) {
|
||||
let Some(m) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id as u32 && m.species() == Species::Ravager)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (center, at, area) = (
|
||||
m.center(),
|
||||
m.position,
|
||||
m.bounds().inflate(Vec3::new(4.0, 4.0, 4.0)),
|
||||
);
|
||||
let options = ParticleOptions::simple(ParticleType::Poof);
|
||||
for _ in 0..40 {
|
||||
let r = self.particles.random();
|
||||
let v = [
|
||||
r.next_gaussian() * 0.2,
|
||||
r.next_gaussian() * 0.2,
|
||||
r.next_gaussian() * 0.2,
|
||||
];
|
||||
self.particles.add(&options, center, v);
|
||||
}
|
||||
if let Some(view) = self.player_view()
|
||||
&& view.alive
|
||||
&& view.bounds.intersects(area)
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
{
|
||||
// strongKnockback.
|
||||
let (xd, zd) = (p.position.x - at.x, p.position.z - at.z);
|
||||
let dd = (xd * xd + zd * zd).max(0.001);
|
||||
p.velocity = p
|
||||
.velocity
|
||||
.add_vector(Vec3::new(xd / dd * 4.0, 0.2, zd / dd * 4.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@ impl Game<'_> {
|
||||
) && !baby
|
||||
}
|
||||
Species::Sheep => item == "minecraft:shears" || item.ends_with("_dye"),
|
||||
// SnowGolem.mobInteract: shears take the pumpkin off.
|
||||
Species::SnowGolem => item == "minecraft:shears" && !m.extra.sheared,
|
||||
// Bucketable.bucketMobPickup.
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
|
||||
@@ -153,6 +153,10 @@ impl Game<'_> {
|
||||
}
|
||||
(3, Value::Bool(visible)) => a.name_visible = *visible,
|
||||
(9, Value::Float(health)) => a.health = *health,
|
||||
// ArmorStand.DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE.
|
||||
(16..=21, Value::Floats(r)) if r.len() == 3 => {
|
||||
a.pose[usize::from(index - 16)] = [r[0], r[1], r[2]];
|
||||
}
|
||||
// ArmorStand.DATA_CLIENT_FLAGS.
|
||||
(15, Value::Byte(flags)) => {
|
||||
a.small = flags & 1 != 0;
|
||||
|
||||
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user