mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
203
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb03afb3d4 | ||
|
|
07e317d350 | ||
|
|
4023708865 | ||
|
|
dc5d571b64 | ||
|
|
7d80fe24f2 | ||
|
|
212a7ec198 | ||
|
|
6fb56a9190 | ||
|
|
7b0db2d5ec | ||
|
|
2e73331cf6 | ||
|
|
217a780f28 | ||
|
|
92167dab3c | ||
|
|
aea024d615 | ||
|
|
9d19e22efe | ||
|
|
1d878f41fd | ||
|
|
a541cbb77b | ||
|
|
88b3b3e879 | ||
|
|
2e5e61600a | ||
|
|
517c54c427 | ||
|
|
48d95663d6 | ||
|
|
3c53df3100 | ||
|
|
22d72feae8 | ||
|
|
bd1f66f5ca | ||
|
|
df7103ef02 | ||
|
|
23db3397a7 | ||
|
|
d713ec4c92 | ||
|
|
1e9eca2dcf | ||
|
|
7f7e591c8a | ||
|
|
f41f360288 | ||
|
|
b8fdda0de9 | ||
|
|
3e22b6e383 | ||
|
|
d9b6483aa2 | ||
|
|
e8c1d1219d | ||
|
|
ac10eff413 | ||
|
|
59d6b525ce | ||
|
|
d24f9f5949 | ||
|
|
167b18d7d0 | ||
|
|
07e935d0bf | ||
|
|
12e0094f1f | ||
|
|
1586018c65 | ||
|
|
8457b35d8a | ||
|
|
830b6890e4 | ||
|
|
0e793be2ac | ||
|
|
324a170a84 | ||
|
|
84fc738ae5 | ||
|
|
5fac4f0f34 | ||
|
|
d65bbfc30f | ||
|
|
784b586757 | ||
|
|
71a1181b10 | ||
|
|
45b80deb02 | ||
|
|
4ae877ab3b | ||
|
|
8aeb393c54 | ||
|
|
5cf1da78e8 | ||
|
|
2779c481ad | ||
|
|
932a2215fc | ||
|
|
cb574cdfdc | ||
|
|
b6ee9b1de8 | ||
|
|
107a25bee3 | ||
|
|
9ba8223b40 | ||
|
|
345650c697 | ||
|
|
8f8aecd2c8 | ||
|
|
7495681901 | ||
|
|
3c820a0751 | ||
|
|
a1fa0149ff | ||
|
|
167c3d14bd | ||
|
|
152fe2bb0a | ||
|
|
6fd328c602 | ||
|
|
5c5ede59c1 | ||
|
|
9dda8e0678 | ||
|
|
42c653be00 | ||
|
|
6b5963d111 | ||
|
|
1284f97df6 | ||
|
|
861ef16307 | ||
|
|
66294b102a | ||
|
|
5f98d0edfe | ||
|
|
523280e781 | ||
|
|
1a0b4ea7e3 | ||
|
|
6330080280 | ||
|
|
3738d8a668 | ||
|
|
c1a7b8ab9f | ||
|
|
28c78e8da8 | ||
|
|
1e5ee00bcb | ||
|
|
b338c4e7c2 | ||
|
|
e42a82e9b9 | ||
|
|
e3f6f9d4dc | ||
|
|
d8b49e8697 | ||
|
|
71b8f39eb5 | ||
|
|
d53114fa82 | ||
|
|
9b5c36e232 | ||
|
|
64279fa1ce | ||
|
|
db0a33da3f | ||
|
|
b5ec5a782f | ||
|
|
4d897de20b | ||
|
|
b463f1c80a | ||
|
|
a8ccf43d2b | ||
|
|
6964ee8ece | ||
|
|
d966b1e59b | ||
|
|
54ab5dcf00 | ||
|
|
4516f54e2b | ||
|
|
38cd3095e5 | ||
|
|
82055fb57d | ||
|
|
bcf2a34a10 | ||
|
|
30027e32a6 | ||
|
|
739d4fe187 | ||
|
|
24744aae44 | ||
|
|
9c3058f756 | ||
|
|
9ca282df1e | ||
|
|
ef0ea5daad | ||
|
|
57213668a0 | ||
|
|
584fa2a9b5 | ||
|
|
6c09ec2cc8 | ||
|
|
5435eb0f17 | ||
|
|
370444eed0 | ||
|
|
ddfa71a46f | ||
|
|
620dfc6e0d | ||
|
|
c3a12bf792 | ||
|
|
498de72da2 | ||
|
|
04672c9613 | ||
|
|
76bebec833 | ||
|
|
fa4f38f824 | ||
|
|
14dac749e3 | ||
|
|
10d0132a53 | ||
|
|
209fa169cd | ||
|
|
5be7c02d0c | ||
|
|
e11471cda0 | ||
|
|
731a35612a | ||
|
|
e184ab7a42 | ||
|
|
a934dac2bd | ||
|
|
87d577e566 | ||
|
|
b5794fa5b3 | ||
|
|
4c9075a072 | ||
|
|
4e82a62489 | ||
|
|
8909e0510c | ||
|
|
9ada76e00a | ||
|
|
a68ee8009f | ||
|
|
bf64f868d1 | ||
|
|
0d496fdc8f | ||
|
|
4cad82b4a9 | ||
|
|
8a5d7bb78d | ||
|
|
26aef75d7c | ||
|
|
348054d26e | ||
|
|
c05dc8af22 | ||
|
|
688733132d | ||
|
|
9dd6933dab | ||
|
|
87b533c5a9 | ||
|
|
7f6e47b02e | ||
|
|
7fdd57ad1e | ||
|
|
dca96dd1df | ||
|
|
e0e484e44b | ||
|
|
b3e1542e09 | ||
|
|
57e8de0007 | ||
|
|
861e7279e0 | ||
|
|
7673d38ed7 | ||
|
|
f2856845f4 | ||
|
|
1d93086e33 | ||
|
|
00e15171d5 | ||
|
|
960b8aabad | ||
|
|
3f1181bab4 | ||
|
|
1596b84299 | ||
|
|
190cf4e0c2 | ||
|
|
cb488cf515 | ||
|
|
6977659843 | ||
|
|
a529be8915 | ||
|
|
d75ef5e4f7 | ||
|
|
da736d56d6 | ||
|
|
a14da6bb70 | ||
|
|
3114fc66e9 | ||
|
|
5589edb118 | ||
|
|
5179f97582 | ||
|
|
26626e8c27 | ||
|
|
13144a82a1 | ||
|
|
728bf14aa7 | ||
|
|
fd0f09d6f6 | ||
|
|
06fb03567e | ||
|
|
c418034f10 | ||
|
|
c273a7271e | ||
|
|
5b9a66089e | ||
|
|
4c72a3a44f | ||
|
|
79bfb8fcb8 | ||
|
|
84aba7b3d4 | ||
|
|
e2a399aff0 | ||
|
|
a4d0f76edd | ||
|
|
00e2cfec29 | ||
|
|
a5fa40d2ee | ||
|
|
0a5e80de7b | ||
|
|
b7c47ccbf9 | ||
|
|
9c3450622b | ||
|
|
3615c65d18 | ||
|
|
e553db9bb5 | ||
|
|
790b191ed1 | ||
|
|
d136dda092 | ||
|
|
a8205dd80d | ||
|
|
f252941ac1 | ||
|
|
30fa41ddbd | ||
|
|
e2781f7939 | ||
|
|
8697f3fd3f | ||
|
|
6bc8a8b889 | ||
|
|
5c0f7142f3 | ||
|
|
09078949d7 | ||
|
|
265965445d | ||
|
|
f5f7448909 | ||
|
|
9e3956282a | ||
|
|
19a5098b14 | ||
|
|
a7ff22eea0 |
No files matched your search
Generated
+7
@@ -1405,6 +1405,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
@@ -3774,6 +3775,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"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1061,6 +1061,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
uuid: PLAYER_UUID,
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
passenger: false,
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
|
||||
@@ -353,6 +353,7 @@ pub fn main() -> Result<()> {
|
||||
horizon_world::food::Difficulty::Hard,
|
||||
][op["difficulty"].as_u64().unwrap() as usize],
|
||||
natural_regeneration: op["regenerate"].as_bool().unwrap(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
"food" => {
|
||||
|
||||
@@ -73,6 +73,8 @@ enum Animate {
|
||||
waterlogged: bool,
|
||||
},
|
||||
EndPortal,
|
||||
/// `WetSpongeBlock.animateTick`.
|
||||
WetSponge,
|
||||
}
|
||||
fn step(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -167,6 +169,10 @@ 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),
|
||||
"suspicious_gravel" => Animate::Falling(0x707070),
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
|
||||
{
|
||||
@@ -963,6 +969,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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -450,6 +450,54 @@ mod tests {
|
||||
assert_eq!(view_chunks(32), 2573);
|
||||
assert_eq!(view_chunks(0), 0);
|
||||
}
|
||||
/// The status a fake control answers with: the player is where it should
|
||||
/// be, nothing is queued, and the world holds `chunks` resident chunks.
|
||||
fn status(chunks: u64) -> String {
|
||||
serde_json::json!({
|
||||
"player": {"position": [0.0, 64.0, 0.0]},
|
||||
"pending_chunks": 0,
|
||||
"screen": "playing",
|
||||
"render_distance": 12,
|
||||
"chunks": chunks,
|
||||
"generated_chunks": chunks,
|
||||
"meshed_sections": 0,
|
||||
"streaming": {},
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_load_waits_for_the_chunks_a_teleport_starts() {
|
||||
// Right after a teleport the game reports steady counts and nothing
|
||||
// queued for over a second, before the integrated server has sent the
|
||||
// first chunk: the quiet rule alone would call that loaded, and the
|
||||
// scene would be measured over an empty view (#1749).
|
||||
const LATE: usize = 30;
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counted = std::sync::Arc::clone(&polls);
|
||||
let server = thread::spawn(move || {
|
||||
let mut seen = 0;
|
||||
while let Ok(request) = rx.recv() {
|
||||
if request.line == "status" {
|
||||
let chunks = if seen < LATE { 0 } else { 400 };
|
||||
seen += 1;
|
||||
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
|
||||
let _ = request.reply.send(status(chunks));
|
||||
} else {
|
||||
let _ = request.reply.send("{}".to_owned());
|
||||
}
|
||||
}
|
||||
});
|
||||
let control = Control(tx);
|
||||
wait_loaded(&control, |_| true).expect("the scene loads");
|
||||
assert!(
|
||||
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
|
||||
"the load finished before the chunks arrived"
|
||||
);
|
||||
drop(control);
|
||||
let _ = server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -40,6 +40,8 @@ pub struct Interaction {
|
||||
/// The cauldron's new state.
|
||||
pub block: Option<BlockState>,
|
||||
pub sound: Option<&'static str>,
|
||||
/// The custom statistic the player earns (`use_cauldron`, ...).
|
||||
pub stat: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn state(name: &str, level: Option<u8>) -> BlockState {
|
||||
@@ -57,6 +59,7 @@ fn pass() -> Interaction {
|
||||
outcome: Outcome::Pass,
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState {
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
|
||||
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
|
||||
Interaction {
|
||||
outcome: Outcome::Success,
|
||||
block: Some(block),
|
||||
sound,
|
||||
stat: Some(stat),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,18 +183,22 @@ pub fn interact(
|
||||
stack.data_mut().dyed_color = None;
|
||||
stack.tidy();
|
||||
}
|
||||
return changed(lowered(cauldron), None);
|
||||
return changed(lowered(cauldron), None, "clean_armor");
|
||||
}
|
||||
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
|
||||
if !can_fill {
|
||||
return pass();
|
||||
}
|
||||
filled(player, ItemStack::new(new, 1), true);
|
||||
changed(state("minecraft:cauldron", None), Some(sound))
|
||||
changed(
|
||||
state("minecraft:cauldron", None),
|
||||
Some(sound),
|
||||
"use_cauldron",
|
||||
)
|
||||
};
|
||||
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
|
||||
filled(player, ItemStack::new("minecraft:bucket", 1), true);
|
||||
changed(block, Some(sound))
|
||||
changed(block, Some(sound), "fill_cauldron")
|
||||
};
|
||||
match item {
|
||||
"minecraft:lava_bucket" => {
|
||||
@@ -199,6 +207,7 @@ pub fn interact(
|
||||
outcome: Outcome::Consume,
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
}
|
||||
} else {
|
||||
empty_bucket(
|
||||
@@ -221,6 +230,7 @@ pub fn interact(
|
||||
outcome: Outcome::Consume,
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
}
|
||||
} else {
|
||||
empty_bucket(
|
||||
@@ -241,6 +251,7 @@ pub fn interact(
|
||||
changed(
|
||||
state("minecraft:water_cauldron", Some(1)),
|
||||
Some("minecraft:item.bottle.empty"),
|
||||
"use_cauldron",
|
||||
)
|
||||
}
|
||||
("water", "minecraft:bucket") => fill_bucket(
|
||||
@@ -255,7 +266,11 @@ pub fn interact(
|
||||
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
|
||||
true,
|
||||
);
|
||||
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
|
||||
changed(
|
||||
lowered(cauldron),
|
||||
Some("minecraft:item.bottle.fill"),
|
||||
"use_cauldron",
|
||||
)
|
||||
}
|
||||
("water", "minecraft:potion") => {
|
||||
if level == 3 || !water_potion {
|
||||
@@ -266,7 +281,7 @@ pub fn interact(
|
||||
// BlockState.cycle on a 1..=3 property
|
||||
next.properties
|
||||
.insert("level".into(), (level % 3 + 1).to_string());
|
||||
changed(next, Some("minecraft:item.bottle.empty"))
|
||||
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
|
||||
}
|
||||
("water", name)
|
||||
if name
|
||||
@@ -283,7 +298,7 @@ pub fn interact(
|
||||
cleaned.data_mut().banner_patterns.pop();
|
||||
cleaned.tidy();
|
||||
filled(player, cleaned, false);
|
||||
changed(lowered(cauldron), None)
|
||||
changed(lowered(cauldron), None, "clean_banner")
|
||||
}
|
||||
("water", name)
|
||||
if name
|
||||
@@ -292,7 +307,7 @@ pub fn interact(
|
||||
.is_some_and(|c| COLORS.contains(&c)) =>
|
||||
{
|
||||
filled(player, held.transmute("minecraft:shulker_box", 1), false);
|
||||
changed(lowered(cauldron), None)
|
||||
changed(lowered(cauldron), None, "clean_shulker_box")
|
||||
}
|
||||
("lava", "minecraft:bucket") => fill_bucket(
|
||||
player,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -728,6 +728,18 @@ impl Furnace {
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -213,8 +214,9 @@ impl PlayerEffects {
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
|
||||
/// (on its last tick). Returns the actions to apply, in order, and
|
||||
/// whether Raid Omen was newly added.
|
||||
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
|
||||
/// answer: the effect ends once the absorption is used up. Returns the
|
||||
/// actions to apply, in order, and whether Raid Omen was newly added.
|
||||
///
|
||||
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
|
||||
/// iterated: vanilla's iterator then throws a
|
||||
@@ -224,6 +226,7 @@ impl PlayerEffects {
|
||||
pub fn tick_with(
|
||||
&mut self,
|
||||
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
|
||||
absorption_empty: bool,
|
||||
) -> (Vec<EffectAction>, bool) {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
let mut actions = Vec::new();
|
||||
@@ -246,6 +249,7 @@ impl PlayerEffects {
|
||||
let answer = match e.id {
|
||||
"bad_omen" => omen(e),
|
||||
"raid_omen" if t == 1 => omen(e),
|
||||
"absorption" if absorption_empty => OmenTick::Remove,
|
||||
_ => OmenTick::Continue,
|
||||
};
|
||||
match answer {
|
||||
@@ -465,13 +469,13 @@ mod tests {
|
||||
};
|
||||
// The first tick adds Raid Omen and stops there (the iterator's
|
||||
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
|
||||
let (_, added) = fx.tick_with(&mut convert);
|
||||
let (_, added) = fx.tick_with(&mut convert, false);
|
||||
assert!(added);
|
||||
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 50);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
|
||||
// The second refreshes Raid Omen and drops Bad Omen.
|
||||
let (_, added) = fx.tick_with(&mut convert);
|
||||
let (_, added) = fx.tick_with(&mut convert, false);
|
||||
assert!(!added);
|
||||
assert!(fx.get("bad_omen").is_none());
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
@@ -486,9 +490,9 @@ mod tests {
|
||||
seen.push(e.duration);
|
||||
OmenTick::Remove
|
||||
};
|
||||
fx.tick_with(&mut watch);
|
||||
fx.tick_with(&mut watch, false);
|
||||
assert!(fx.get("raid_omen").is_some());
|
||||
fx.tick_with(&mut watch);
|
||||
fx.tick_with(&mut watch, false);
|
||||
assert!(fx.get("raid_omen").is_none());
|
||||
assert_eq!(seen, vec![1]);
|
||||
}
|
||||
|
||||
@@ -582,17 +582,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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)),
|
||||
@@ -466,7 +491,32 @@ 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))),
|
||||
);
|
||||
}
|
||||
"minecraft:parrot" => {
|
||||
map.insert(
|
||||
"Variant".into(),
|
||||
Tag::Int(i32::from(m.extra.variant.min(4))),
|
||||
);
|
||||
}
|
||||
"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();
|
||||
@@ -900,6 +950,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 {
|
||||
@@ -1048,6 +1117,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 {
|
||||
@@ -2229,6 +2299,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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -63,7 +63,6 @@ mod frames;
|
||||
mod friends;
|
||||
mod functions;
|
||||
mod game_mode;
|
||||
mod game_rules_screen;
|
||||
mod gamemode_switcher;
|
||||
mod ghasts;
|
||||
mod golems;
|
||||
@@ -571,6 +570,8 @@ struct GameLevel<'w> {
|
||||
player_collision: Option<PlayerCollision>,
|
||||
/// Minecart boxes (detector rails).
|
||||
cart_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
|
||||
cart_signals: Vec<(minecarts::CartKind, i32)>,
|
||||
/// Arrow and thrown trident boxes (wooden buttons).
|
||||
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// The other entities that keep blocks from being placed into them.
|
||||
@@ -649,6 +650,37 @@ impl EntityObstructionWorld for BuildingObstruction {
|
||||
}
|
||||
}
|
||||
impl GameLevel<'_> {
|
||||
/// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the
|
||||
/// minecart on it, a command block cart first (its success count, which
|
||||
/// is 0 here), else the first container cart's fullness.
|
||||
fn detector_rail_signal(&self, pos: BlockPos, powered: bool) -> i32 {
|
||||
if !powered {
|
||||
return 0;
|
||||
}
|
||||
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
|
||||
let area = Aabb::new(
|
||||
Vec3::new(x + 0.2, y, z + 0.2),
|
||||
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
|
||||
);
|
||||
let mut container = None;
|
||||
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
|
||||
let cart = Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
);
|
||||
if !area.intersects(cart) {
|
||||
continue;
|
||||
}
|
||||
match kind {
|
||||
minecarts::CartKind::CommandBlock => return 0,
|
||||
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
|
||||
container = container.or(Some(signal));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
container.unwrap_or(0)
|
||||
}
|
||||
/// Every entity box that blocks building: the players who aren't
|
||||
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
|
||||
/// armor stand, whose box is empty, doesn't).
|
||||
@@ -709,6 +741,14 @@ impl LevelAccess for GameLevel<'_> {
|
||||
state: BlockStateId,
|
||||
_direction: Direction,
|
||||
) -> i32 {
|
||||
if let Some(s) = self.registry.state(state)
|
||||
&& s.name == "minecraft:detector_rail"
|
||||
{
|
||||
return self.detector_rail_signal(
|
||||
pos,
|
||||
s.properties.get("powered").is_some_and(|v| v == "true"),
|
||||
);
|
||||
}
|
||||
if let Some(signal) = command_blocks::analog_signal(self.world, (pos.x, pos.y, pos.z)) {
|
||||
return signal;
|
||||
}
|
||||
@@ -2050,9 +2090,11 @@ impl Game<'_> {
|
||||
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
|
||||
/// (decreaseAirSupply), the wings, leather boots on powder snow
|
||||
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
|
||||
/// (LivingEntity.canFreeze) and the dimension's FAST_LAVA.
|
||||
/// (LivingEntity.canFreeze), the dimension's FAST_LAVA and the sleeping
|
||||
/// pose.
|
||||
pub(crate) fn sync_player_body(&mut self) {
|
||||
let glider = self.glider_worn();
|
||||
let sleeping = self.sleeping.is_some();
|
||||
let item_use_speed = self
|
||||
.using
|
||||
.and_then(|(slot, _)| {
|
||||
@@ -2080,6 +2122,7 @@ impl Game<'_> {
|
||||
player.item_use_speed = item_use_speed;
|
||||
player.respiration = respiration;
|
||||
player.glider = glider;
|
||||
let _ = player.set_sleeping(sleeping);
|
||||
player.leather_boots = leather_boots;
|
||||
player.freeze_immune = freeze_immune;
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
@@ -2575,8 +2618,14 @@ impl Game<'_> {
|
||||
pickups::Taken::Item(Box::new(item.clone())),
|
||||
pickups::Collector::Player,
|
||||
);
|
||||
// handleTakeItemEntity: the random pitch of a full pickup.
|
||||
let pitch = ((self.world.random.next_float()
|
||||
- self.world.random.next_float())
|
||||
* 0.7
|
||||
+ 1.0)
|
||||
* 2.0;
|
||||
self.audio
|
||||
.play("minecraft:entity.item.pickup", Some(feet), 0.2, 1.0);
|
||||
.play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch);
|
||||
}
|
||||
item.stack = rest;
|
||||
}
|
||||
@@ -2633,6 +2682,7 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let cart_signals = self.cart_signals();
|
||||
let arrow_boxes = self
|
||||
.creatures
|
||||
.projectiles
|
||||
@@ -2675,6 +2725,7 @@ impl Game<'_> {
|
||||
};
|
||||
let mut level = GameLevel {
|
||||
cart_boxes,
|
||||
cart_signals,
|
||||
arrow_boxes,
|
||||
blockers,
|
||||
world: &mut self.world,
|
||||
@@ -3071,6 +3122,34 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.composter_fill_event(pos, data == 1),
|
||||
// LevelEventHandler 1504: the stalactite's drip particle.
|
||||
LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos),
|
||||
// LevelEventHandler 2006: the dragon fireball's burst.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2006,
|
||||
pos,
|
||||
data,
|
||||
} => self.dragon_fireball_burst(pos, data),
|
||||
// LevelEventHandler 2009: a wet sponge drying.
|
||||
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
|
||||
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
|
||||
}
|
||||
// LevelEventHandler 2012: a turtle egg laid.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2012,
|
||||
pos,
|
||||
data,
|
||||
} => self.turtle_egg_particles(pos, data),
|
||||
// LevelEventHandler 2014: a straw bed destroyed, with no sound.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2014,
|
||||
pos,
|
||||
data,
|
||||
} => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)),
|
||||
// LevelEventHandler 3009: a sniffer egg cracking.
|
||||
LevelEvent::LevelEvent { id: 3009, pos, .. } => {
|
||||
self.sniffer_egg_crack_particles(pos)
|
||||
}
|
||||
LevelEvent::LevelEvent {
|
||||
id: id @ 3011..=3021,
|
||||
pos,
|
||||
@@ -3086,6 +3165,16 @@ impl Game<'_> {
|
||||
1501 => Some(("minecraft:block.lava.extinguish", 0.5)),
|
||||
1009 => Some(("minecraft:block.fire.extinguish", 0.5)),
|
||||
1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)),
|
||||
1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)),
|
||||
1034 => Some(("minecraft:block.chorus_flower.death", 1.0)),
|
||||
1046 => Some((
|
||||
"minecraft:block.pointed_dripstone.drip_lava_into_cauldron",
|
||||
2.0,
|
||||
)),
|
||||
1047 => Some((
|
||||
"minecraft:block.pointed_dripstone.drip_water_into_cauldron",
|
||||
2.0,
|
||||
)),
|
||||
// LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break).
|
||||
1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)),
|
||||
1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)),
|
||||
@@ -3107,6 +3196,13 @@ impl Game<'_> {
|
||||
- self.world.random.next_float())
|
||||
* 0.8
|
||||
}
|
||||
// LevelEventHandler draws these from the client
|
||||
// level's random, before 1501's and 1502's smoke.
|
||||
1501 | 1502 => {
|
||||
let r = self.particles.level_random();
|
||||
2.6 + (r.next_float() - r.next_float()) * 0.8
|
||||
}
|
||||
1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9,
|
||||
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
|
||||
self.world.random.next_float() * 0.1 + 0.9
|
||||
}
|
||||
@@ -3125,6 +3221,15 @@ impl Game<'_> {
|
||||
self.audio.play(name, c, volume, pitch);
|
||||
}
|
||||
}
|
||||
// LevelEventHandler 1501 and 1502 draw their smoke after the sound.
|
||||
match id {
|
||||
1501 => self.puffs_above_block(
|
||||
pos,
|
||||
horizon_client::particles::ParticleType::LargeSmoke,
|
||||
),
|
||||
1502 => self.torch_burnout_smoke(pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
LevelEvent::Sound {
|
||||
pos,
|
||||
@@ -3191,6 +3296,8 @@ impl Game<'_> {
|
||||
// Popped as the behaviour emits it (GameLevel::emit).
|
||||
PlayerEffect::DropItem(_) => {}
|
||||
PlayerEffect::EatCake => {
|
||||
// CakeBlock.eat
|
||||
self.stats.custom("eat_cake_slice", 1);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
let food = &mut p.health.food;
|
||||
food.food = (food.food + 2).min(20);
|
||||
@@ -3324,11 +3431,18 @@ impl Game<'_> {
|
||||
self.open_command_block(key);
|
||||
return;
|
||||
}
|
||||
// SignBlock.useWithoutItem: the text editor, not a container.
|
||||
if horizon_client::signs::sign_kind(&name).is_some() {
|
||||
self.use_sign_empty_hand(pos);
|
||||
return;
|
||||
}
|
||||
if &*name == "minecraft:crafting_table" {
|
||||
self.craft_size = 3;
|
||||
self.craft_table = Some(pos);
|
||||
} else if &*name == "minecraft:ender_chest" {
|
||||
self.open_ender_chest(key);
|
||||
// EnderChestBlock.useWithoutItem
|
||||
self.stats.custom("open_enderchest", 1);
|
||||
self.container_opener_event(key, true);
|
||||
let at = [
|
||||
f64::from(key.0) + 0.5,
|
||||
@@ -3720,6 +3834,21 @@ impl Game<'_> {
|
||||
{
|
||||
self.hive_broken_by_player(pos, hive, honey, facing);
|
||||
}
|
||||
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
|
||||
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
|
||||
if facts.name.starts_with("minecraft:infested_")
|
||||
&& !silk
|
||||
&& self.ui.rules.bool("block_drops")
|
||||
{
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) {
|
||||
self.mob_poof(id);
|
||||
}
|
||||
}
|
||||
// SpawnerBlock.spawnAfterBreak pops its experience whatever the tool.
|
||||
let xp = if silk && &*facts.name != "minecraft:spawner" {
|
||||
0
|
||||
@@ -3841,6 +3970,10 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_sign_applicator(hit.pos, held.as_ref()) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_jukebox(hit.pos, held.as_ref()) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -3900,6 +4033,20 @@ impl Game<'_> {
|
||||
)
|
||||
});
|
||||
self.process_level_events()?;
|
||||
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
|
||||
if result == horizon_world::level::InteractionResult::Success {
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name
|
||||
.clone();
|
||||
match &*name {
|
||||
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
|
||||
"minecraft:bell" => self.stats.custom("bell_ring", 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if result.consumes_action() {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -4688,7 +4835,6 @@ impl Game<'_> {
|
||||
player.dimensions.eye_height
|
||||
};
|
||||
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
|
||||
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
|
||||
// `LivingEntity.baseTick` (Soul Speed) comes before `aiStep` (the
|
||||
// glide's wear): both draw from the level random.
|
||||
if let Some((at, velocity)) = self.tick_soul_speed() {
|
||||
@@ -4724,10 +4870,12 @@ impl Game<'_> {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
|
||||
MotionEvent::Emission {
|
||||
state, in_water, ..
|
||||
} if !crouching => {
|
||||
} => {
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Entity.playStepSound: the block's step sound at 0.15 volume,
|
||||
// muffled under the swim sound in water.
|
||||
// muffled under the swim sound in water. Whether it plays is
|
||||
// Player.getMovementEmission's call (shift on the ground),
|
||||
// not the pose: a forced crouch still steps.
|
||||
let (volume, pitch) = if in_water {
|
||||
(sounds.volume * 0.05, sounds.pitch * 0.8)
|
||||
} else {
|
||||
@@ -4739,26 +4887,21 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
|
||||
}
|
||||
MotionEvent::Chime { volume, pitch } => {
|
||||
self.audio.play(
|
||||
"minecraft:block.amethyst_block.chime",
|
||||
Some(feet),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
MotionEvent::Drown => {
|
||||
// LivingEntity.makeDrownParticles: eight bubbles moving
|
||||
// with the player.
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
for _ in 0..8 {
|
||||
let mut triangle = || {
|
||||
let r = self.particles.random();
|
||||
r.next_double() - r.next_double()
|
||||
};
|
||||
let p = [
|
||||
feet[0] + triangle(),
|
||||
feet[1] + triangle(),
|
||||
feet[2] + triangle(),
|
||||
];
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::Bubble),
|
||||
p,
|
||||
player_velocity,
|
||||
);
|
||||
// The guests see the entity event 67 too.
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub
|
||||
.entity_event(host.host_entity, combat::DROWN_EVENT, feet);
|
||||
}
|
||||
self.drown_particles(feet, player_velocity);
|
||||
}
|
||||
MotionEvent::BubbleColumnParticle { bubble, position } => {
|
||||
// ServerLevel.sendParticles: SPLASH (speed 1) or BUBBLE
|
||||
@@ -4794,6 +4937,10 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
// Player.causeFallDamage: a fall of 2 blocks or more counts.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) => {
|
||||
self.stats.custom("fall_one_cm", cm)
|
||||
}
|
||||
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
|
||||
state,
|
||||
@@ -4861,6 +5008,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.powder_snow_fire()?;
|
||||
self.rain_extinguish_in_frame();
|
||||
self.tick_totem();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
@@ -7935,13 +8083,15 @@ impl Game<'_> {
|
||||
}
|
||||
/// The visual and sound part of `Player.doSweepAttack` after a successful hit.
|
||||
pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) {
|
||||
self.particles.add(
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::SweepAttack,
|
||||
),
|
||||
at,
|
||||
[0.0; 3],
|
||||
let sweep = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::SweepAttack,
|
||||
);
|
||||
self.particles.add(&sweep, at, [0.0; 3]);
|
||||
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
|
||||
// the offset is the one from the player to the particle.
|
||||
let origin = from.unwrap_or(at);
|
||||
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
|
||||
self.lan_send_particles(&sweep, at, spread, 0.0, 0);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
|
||||
}
|
||||
@@ -8096,11 +8246,6 @@ impl Game<'_> {
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
|
||||
self.attack_ticks = 0;
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
let player = self.player.as_ref();
|
||||
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if knockback_attack {
|
||||
@@ -8180,6 +8325,14 @@ impl Game<'_> {
|
||||
0.2,
|
||||
(actual * 0.5) as u32,
|
||||
);
|
||||
// The players a server serves are sent them too.
|
||||
self.lan_send_particles(
|
||||
&indicator,
|
||||
[feet[0], feet[1] + height * 0.5, feet[2]],
|
||||
[0.1, 0.0, 0.1],
|
||||
0.2,
|
||||
(actual * 0.5) as u32,
|
||||
);
|
||||
}
|
||||
self.last_hurt_by_player
|
||||
.insert(self.mobs[index].id, self.lan_acting);
|
||||
@@ -8210,6 +8363,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.weapon_hit(index);
|
||||
// Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt
|
||||
// (inside `if (wasHurt)`), after damageStatsAndHearts.
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
} else {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
|
||||
@@ -8773,6 +8933,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.lan_close(false);
|
||||
self.save_game()?;
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
|
||||
remote.session.disconnect();
|
||||
}
|
||||
self.saved_for_exit = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -9054,6 +9218,9 @@ impl Game<'_> {
|
||||
if result.outcome == Outcome::Pass {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(stat) = result.stat {
|
||||
self.stats.custom(stat, 1);
|
||||
}
|
||||
if let Some(block) = result.block
|
||||
&& let Ok(new) = registry.resolve(&block)
|
||||
{
|
||||
@@ -9756,16 +9923,13 @@ impl Game<'_> {
|
||||
/// sound every fifth level (at most every 100 ticks).
|
||||
pub(super) fn give_orb_points(&mut self, gained: u32) {
|
||||
let at = self.eye_position();
|
||||
if self.experience.give(gained as i32)
|
||||
&& self.experience.level % 5 == 0
|
||||
// Player.giveExperienceLevels runs per level, so the first multiple of 5
|
||||
// crossed plays the sound (and starts the cooldown for the rest).
|
||||
if let Some(level) = self.experience.give(gained as i32)
|
||||
&& self.ticks > self.last_level_up + 100
|
||||
{
|
||||
self.last_level_up = self.ticks;
|
||||
let volume = if self.experience.level > 30 {
|
||||
1.0
|
||||
} else {
|
||||
self.experience.level as f32 / 30.0
|
||||
};
|
||||
let volume = if level > 30 { 1.0 } else { level as f32 / 30.0 };
|
||||
self.audio
|
||||
.play("minecraft:entity.player.levelup", at, volume * 0.75, 1.0);
|
||||
}
|
||||
@@ -9830,7 +9994,7 @@ impl Game<'_> {
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
self.player_projectile_shot();
|
||||
let pitch = 1.0 / (self.world.random.next_float() * 0.4 + 1.2) + power * 0.5;
|
||||
self.audio
|
||||
@@ -10135,6 +10299,14 @@ impl Game<'_> {
|
||||
};
|
||||
Some(t < 0.15)
|
||||
}
|
||||
/// `Level.isRainingAt`: it rains, the sky light is 15, nothing that stops
|
||||
/// motion is above, and the biome's precipitation there is rain.
|
||||
fn raining_at(&self, p: BlockPos) -> bool {
|
||||
self.world.weather.levels(1.0).0 > 0.2
|
||||
&& self.world.light(p.x, p.y, p.z) >> 4 == 15
|
||||
&& self.rain_height(p.x, p.z) <= p.y
|
||||
&& self.precipitation(p.x, p.y, p.z) == Some(false)
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
|
||||
/// highest block in `blocks_motion_in_heightmap` or holding a fluid.
|
||||
fn rain_height(&self, x: i32, z: i32) -> i32 {
|
||||
@@ -10171,12 +10343,15 @@ impl Game<'_> {
|
||||
} else {
|
||||
("minecraft:weather.rain", 0.2, 1.0)
|
||||
};
|
||||
self.audio.play(
|
||||
name,
|
||||
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
// The Horizon Weather option off silences the rain too.
|
||||
if self.settings.horizon.weather {
|
||||
self.audio.play(
|
||||
name,
|
||||
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10719,6 +10894,9 @@ impl Game<'_> {
|
||||
let level = &mut self.level;
|
||||
level.creative = self.creative;
|
||||
level.game_mode = mode;
|
||||
// PrimaryLevelData.setDifficulty: /difficulty and the options button change it.
|
||||
level.difficulty =
|
||||
horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into();
|
||||
level.time = self.world.game_time;
|
||||
level.weather = self.world.weather;
|
||||
level.day_time = self.world.day_time;
|
||||
@@ -10874,6 +11052,21 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
// A server pack's reload covers the frame with its progress
|
||||
// (`LoadingOverlay`) until `tick_server_packs` applies it.
|
||||
if let Some(reload) = &self.server_packs.reload {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
horizon_client::level_loading::draw_progress(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
reload.progress.line(),
|
||||
reload.progress.fraction(),
|
||||
);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -10994,7 +11187,11 @@ impl Game<'_> {
|
||||
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
|
||||
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
|
||||
let mut outline_after_terrain = false;
|
||||
let outline = if self.screen == Screen::Playing && !self.spectator() {
|
||||
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
|
||||
// player who may build. Adventure players only get one with a CAN_BREAK or
|
||||
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
|
||||
// components nor opens menus for spectators, so those never show it.
|
||||
let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
|
||||
self.pick()
|
||||
.map(|h| {
|
||||
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
|
||||
@@ -11190,6 +11387,7 @@ impl Game<'_> {
|
||||
dark_disc: self.dark_disc(camera),
|
||||
outline,
|
||||
outline_after_terrain,
|
||||
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
|
||||
debug_lines: &debug_lines,
|
||||
debug_lines_on_top: &debug_lines_on_top,
|
||||
debug_crosshair: &debug_crosshair,
|
||||
@@ -11249,7 +11447,7 @@ impl Game<'_> {
|
||||
}),
|
||||
ticks: self.ticks,
|
||||
darkness: self.darkness_lighting(alpha as f32),
|
||||
night_vision: self.night_vision_intensity(),
|
||||
night_vision: self.night_vision_intensity(alpha as f32),
|
||||
block_flicker: self.fog.block_light_flicker,
|
||||
boss_darken: self
|
||||
.boss_overlay
|
||||
@@ -11956,6 +12154,19 @@ impl ApplicationHandler for Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The pack reload's progress overlay takes no input
|
||||
// (`LoadingOverlay`): `start_pack_reload` released what was held.
|
||||
if self.server_packs.reload.is_some()
|
||||
&& !matches!(
|
||||
&event,
|
||||
WindowEvent::CloseRequested
|
||||
| WindowEvent::Focused(_)
|
||||
| WindowEvent::Resized(_)
|
||||
| WindowEvent::RedrawRequested
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.pending_save.is_some() {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
@@ -12179,7 +12390,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
let (action, sound) = self.title.as_mut().map_or((Action::None, false), |t| {
|
||||
let clicked = t.click(self.cursor.0, self.cursor.1, (b.width, b.height));
|
||||
t.press_field(atlas, self.cursor, (b.width, b.height), shift);
|
||||
clicked
|
||||
let rules = t.press_rules(atlas, self.cursor, (b.width, b.height));
|
||||
(clicked.0, clicked.1 || rules)
|
||||
});
|
||||
self.save_title_options();
|
||||
if sound {
|
||||
@@ -12757,6 +12969,16 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if horizon_threads::stop_requests() > 0
|
||||
&& self.pending_save.as_ref().is_none_or(|save| !save.quit)
|
||||
{
|
||||
// SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close.
|
||||
if let Err(error) = self.begin_closing_save(true) {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.pending_save.is_some() {
|
||||
match self.poll_closing_save() {
|
||||
Ok(true) => event_loop.exit(),
|
||||
@@ -13452,7 +13674,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [
|
||||
KeyCode::Numpad9,
|
||||
];
|
||||
|
||||
const STAGES: u32 = 10;
|
||||
const STAGE_LINES: [&str; 10] = [
|
||||
"Loading menu",
|
||||
"Loading assets",
|
||||
@@ -13468,19 +13689,28 @@ const STAGE_LINES: [&str; 10] = [
|
||||
|
||||
/// The background loader's checkpoint, read by the loading screen each frame.
|
||||
#[derive(Clone)]
|
||||
struct Progress(Arc<AtomicU32>);
|
||||
struct Progress {
|
||||
value: Arc<AtomicU32>,
|
||||
lines: &'static [&'static str],
|
||||
}
|
||||
impl Progress {
|
||||
fn new() -> Self {
|
||||
Self(Arc::new(AtomicU32::new(0)))
|
||||
Self::with(&STAGE_LINES)
|
||||
}
|
||||
fn with(lines: &'static [&'static str]) -> Self {
|
||||
Self {
|
||||
value: Arc::new(AtomicU32::new(0)),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
fn set(&self, stage: u32) {
|
||||
self.0.store(stage, Ordering::Relaxed);
|
||||
self.value.store(stage, Ordering::Relaxed);
|
||||
}
|
||||
fn fraction(&self) -> f32 {
|
||||
self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32
|
||||
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
|
||||
}
|
||||
fn line(&self) -> &'static str {
|
||||
STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)]
|
||||
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13934,6 +14164,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
@@ -14246,6 +14477,11 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if horizon_threads::stop_requests() > 0 {
|
||||
// No world is open yet: nothing to save.
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.poll_menu_resources() {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
@@ -14523,6 +14759,9 @@ pub fn run() -> Result<()> {
|
||||
{
|
||||
horizon_client::heartbeat::start();
|
||||
}
|
||||
if options.execution_role.is_client() {
|
||||
horizon_threads::catch_stop_signals();
|
||||
}
|
||||
let started = Instant::now();
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
|
||||
@@ -708,6 +708,27 @@ impl Game<'_> {
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `TradeTrigger`: the villager and the item the trade gave.
|
||||
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
let villager = villager.clone();
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:villager_trade", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let vctx = adv::Entities {
|
||||
this: Some(&villager),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::conditions(&c["villager"], &vctx, w)
|
||||
&& adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// `EnterBlockTrigger`: the block, its state properties and the player.
|
||||
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
|
||||
@@ -131,7 +131,8 @@ impl Game<'_> {
|
||||
let m = &mut self.mobs[i];
|
||||
m.extra.ints[1] -= 1;
|
||||
m.extra.ints[3] = 60;
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
|
||||
// ItemEntity.setDefaultPickUpDelay.
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
|
||||
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
|
||||
const PITCHES: [f32; 16] = [
|
||||
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -789,6 +789,11 @@ impl Game<'_> {
|
||||
pub(super) fn sync_rules(&mut self) {
|
||||
self.world.health_rules.natural_regeneration =
|
||||
self.ui.rules.bool("natural_health_regeneration");
|
||||
// Player.isInvulnerableTo: the damage-type rules.
|
||||
self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage");
|
||||
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
|
||||
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
|
||||
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
|
||||
use horizon_world::food::Difficulty;
|
||||
self.world.health_rules.difficulty = match self.ui.difficulty {
|
||||
0 => Difficulty::Peaceful,
|
||||
@@ -801,7 +806,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 +835,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 +885,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).
|
||||
@@ -988,34 +994,6 @@ 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).
|
||||
|
||||
@@ -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,
|
||||
@@ -45,6 +54,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 +123,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;
|
||||
};
|
||||
|
||||
@@ -42,9 +42,10 @@ pub(super) enum Target {
|
||||
Guest(u32),
|
||||
Mob(u32),
|
||||
Item(u32),
|
||||
Orb(usize),
|
||||
/// By index in `creatures.projectiles`.
|
||||
Projectile(usize),
|
||||
/// An experience orb, by its entity id.
|
||||
Orb(u32),
|
||||
/// A projectile, by its entity id.
|
||||
Projectile(u32),
|
||||
Stand(u32),
|
||||
Frame(u32),
|
||||
Painting(u32),
|
||||
@@ -53,6 +54,10 @@ pub(super) enum Target {
|
||||
/// An entity of a type Horizon does not simulate (markers, displays,
|
||||
/// interactions...): kept as its saved data at a position.
|
||||
Other(u32),
|
||||
/// An End crystal, by its entity id (`minecraft:end_crystal`).
|
||||
Crystal(u32),
|
||||
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
|
||||
Dragon(u32),
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate: its compound lives in the
|
||||
/// entity memory (`persist.raw`), its position and rotation here.
|
||||
@@ -340,14 +345,16 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| [i.position.x, i.position.y, i.position.z]),
|
||||
Target::Orb(i) => self
|
||||
Target::Orb(id) => self
|
||||
.orbs
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| [a.position.x, a.position.y, a.position.z]),
|
||||
Target::Stand(id) => self
|
||||
.armor_stands
|
||||
@@ -385,6 +392,16 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Crystal(id) => self
|
||||
.crystals
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Dragon(id) => self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|d| [d.position.x, d.position.y, d.position.z]),
|
||||
}
|
||||
}
|
||||
pub(super) fn target_type(&self, t: Target) -> String {
|
||||
@@ -399,10 +416,11 @@ impl Game<'_> {
|
||||
}),
|
||||
Target::Item(_) => "minecraft:item".into(),
|
||||
Target::Orb(_) => "minecraft:experience_orb".into(),
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.map_or_else(String::new, |p| p.kind.id().into()),
|
||||
Target::Stand(_) => "minecraft:armor_stand".into(),
|
||||
Target::Frame(id) => {
|
||||
@@ -428,6 +446,8 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map_or_else(String::new, |o| o.kind.clone()),
|
||||
Target::Crystal(_) => "minecraft:end_crystal".into(),
|
||||
Target::Dragon(_) => "minecraft:ender_dragon".into(),
|
||||
}
|
||||
}
|
||||
/// The name of the player the game acts for: a guest's own while their
|
||||
@@ -512,50 +532,72 @@ impl Game<'_> {
|
||||
s.pos[2].unwrap_or(p[2]),
|
||||
]
|
||||
};
|
||||
let mut candidates: Vec<Target> = Vec::new();
|
||||
let mut source_candidates: Vec<Target> = Vec::new();
|
||||
match (s.kind, self.command_source) {
|
||||
// @s: the executing entity (none for a server source).
|
||||
('s', Some(source)) => candidates.extend(source.entity),
|
||||
('s', None) => candidates.extend(self.source_entity()),
|
||||
('s', Some(source)) => source_candidates.extend(source.entity),
|
||||
('s', None) => source_candidates.extend(self.source_entity()),
|
||||
// A guest in place is among the players, in join order.
|
||||
_ if self.player.is_some() && self.lan_acting.is_none() => {
|
||||
candidates.push(Target::Player);
|
||||
source_candidates.push(Target::Player);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if s.kind != 's' {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
candidates.extend(self.player_candidates(s));
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.death_time == 0)
|
||||
.map(|m| Target::Mob(m.id)),
|
||||
);
|
||||
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
|
||||
candidates.extend((0..self.orbs.len()).map(Target::Orb));
|
||||
candidates.extend(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|(_, p)| {
|
||||
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|(i, _)| Target::Projectile(i)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
|
||||
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
source_candidates.extend(self.player_candidates(s));
|
||||
}
|
||||
let source_dim = self.dim;
|
||||
// A selector with a spatial constraint reaches the level the command
|
||||
// runs in only; an unbounded one reaches every loaded level
|
||||
// (EntitySelector.findEntities).
|
||||
let dims = if s.world_limited() {
|
||||
vec![source_dim]
|
||||
} else {
|
||||
self.loaded_dims()
|
||||
};
|
||||
let mut out: Vec<(Target, f64)> = Vec::new();
|
||||
for dim in dims {
|
||||
let back = self.enter_command_level(dim);
|
||||
let mut candidates = Vec::new();
|
||||
if dim == source_dim {
|
||||
candidates.append(&mut source_candidates);
|
||||
}
|
||||
if s.kind != 's' {
|
||||
candidates.extend(self.level_candidates());
|
||||
}
|
||||
out.extend(self.select_in_level(candidates, s, origin));
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
}
|
||||
match s.sort {
|
||||
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
|
||||
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
|
||||
Sort::Random => {
|
||||
for i in (1..out.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
out.swap(i, j);
|
||||
}
|
||||
}
|
||||
Sort::Arbitrary => {}
|
||||
}
|
||||
out.into_iter()
|
||||
.take(s.limit.max(1))
|
||||
.map(|(t, _)| t)
|
||||
.collect()
|
||||
}
|
||||
/// The candidates of the level in place that pass the selector
|
||||
/// (EntitySelector.getPredicate).
|
||||
fn select_in_level(
|
||||
&mut self,
|
||||
candidates: Vec<Target>,
|
||||
s: &Selector,
|
||||
origin: [f64; 3],
|
||||
) -> Vec<(Target, f64)> {
|
||||
let level = self.experience.level;
|
||||
let creative = self.creative;
|
||||
let passing = self.selector_extra_filter(&candidates, s);
|
||||
let mut out: Vec<(Target, f64)> = candidates
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
let p = self.target_position(t)?;
|
||||
@@ -627,23 +669,102 @@ impl Game<'_> {
|
||||
}
|
||||
Some((t, d2))
|
||||
})
|
||||
.collect();
|
||||
match s.sort {
|
||||
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
|
||||
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
|
||||
Sort::Random => {
|
||||
for i in (1..out.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
out.swap(i, j);
|
||||
}
|
||||
}
|
||||
Sort::Arbitrary => {}
|
||||
}
|
||||
out.into_iter()
|
||||
.take(s.limit.max(1))
|
||||
.map(|(t, _)| t)
|
||||
.collect()
|
||||
}
|
||||
/// The entities of the level in place a selector can find.
|
||||
fn level_candidates(&self) -> Vec<Target> {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let mut candidates = Vec::new();
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.death_time == 0)
|
||||
.map(|m| Target::Mob(m.id)),
|
||||
);
|
||||
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
|
||||
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
|
||||
candidates.extend(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|p| {
|
||||
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|p| Target::Projectile(p.id)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
|
||||
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
// The End crystals and the dragon (EndCrystal and EnderDragon
|
||||
// are entities of their own).
|
||||
candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id)));
|
||||
if let Some(d) = &self.dragon {
|
||||
candidates.push(Target::Dragon(d.id));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
/// The loaded levels, in the order the server holds them: the
|
||||
/// overworld, the nether, the end (MinecraftServer.getAllLevels).
|
||||
fn loaded_dims(&self) -> Vec<Dim> {
|
||||
let mut dims = vec![self.dim];
|
||||
dims.extend(self.levels.parked_dims());
|
||||
dims.sort_by_key(|d| match d {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => 1,
|
||||
Dim::End => 2,
|
||||
});
|
||||
dims
|
||||
}
|
||||
/// Whether the level in place holds the entity a target names.
|
||||
fn level_holds(&self, t: Target) -> bool {
|
||||
match t {
|
||||
// A player is in no level of its own.
|
||||
Target::Player | Target::Guest(_) => true,
|
||||
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
|
||||
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
|
||||
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
|
||||
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
|
||||
Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id),
|
||||
Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id),
|
||||
Target::Painting(id) => self.paintings.iter().any(|p| p.id == id),
|
||||
Target::Boat(id) => self.boats.iter().any(|b| b.id == id),
|
||||
Target::Cart(id) => self.carts.iter().any(|c| c.id == id),
|
||||
Target::Other(id) => self.others.iter().any(|o| o.id == id),
|
||||
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
|
||||
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
|
||||
}
|
||||
}
|
||||
/// Run `f` with the level that holds the entity a target names in place
|
||||
/// (the entity ids are unique across the loaded levels) and put the
|
||||
/// level back afterwards. `f` does not run when no level holds the
|
||||
/// entity.
|
||||
pub(in crate::game) fn in_target_level<R>(
|
||||
&mut self,
|
||||
t: Target,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
if self.level_holds(t) {
|
||||
return Some(f(self));
|
||||
}
|
||||
for dim in self.levels.parked_dims() {
|
||||
let back = self.enter_command_level(dim);
|
||||
if self.level_holds(t) {
|
||||
let r = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool {
|
||||
let path = self.options.resources.join(format!(
|
||||
"data/minecraft/tags/entity_type/{}.json",
|
||||
@@ -1103,6 +1224,10 @@ impl Game<'_> {
|
||||
Ok(targets.len() as i32)
|
||||
}
|
||||
fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
// An entity in another level is teleported in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
|
||||
}
|
||||
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
let v = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
@@ -1151,14 +1276,14 @@ impl Game<'_> {
|
||||
i.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Orb(i) => {
|
||||
if let Some(o) = self.orbs.get_mut(i) {
|
||||
Target::Orb(id) => {
|
||||
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
|
||||
o.position = v;
|
||||
o.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Projectile(i) => {
|
||||
if let Some(a) = self.creatures.projectiles.get_mut(i) {
|
||||
Target::Projectile(id) => {
|
||||
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
}
|
||||
@@ -1198,6 +1323,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
|
||||
c.position = v;
|
||||
}
|
||||
}
|
||||
Target::Dragon(id) => {
|
||||
if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) {
|
||||
d.position = v;
|
||||
d.previous = v;
|
||||
}
|
||||
}
|
||||
// Wall-mounted entities do not move.
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
@@ -1557,6 +1693,10 @@ impl Game<'_> {
|
||||
Ok(duration.unwrap_or(-1))
|
||||
}
|
||||
fn kill_target(&mut self, t: Target) {
|
||||
// An entity in another level is killed in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
|
||||
}
|
||||
fn kill_target_in_level(&mut self, t: Target) {
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
self.for_player(t, |g| g.kill_target(Target::Player));
|
||||
@@ -1576,16 +1716,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Target::Item(id) => self.dropped.retain(|i| i.id != id),
|
||||
Target::Orb(i) => {
|
||||
if i < self.orbs.len() {
|
||||
self.orbs[i].age = u32::MAX / 2;
|
||||
Target::Orb(id) => {
|
||||
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
|
||||
o.age = u32::MAX / 2;
|
||||
}
|
||||
}
|
||||
// cmd_kill goes from the highest index down.
|
||||
Target::Projectile(i) => {
|
||||
if i < self.creatures.projectiles.len() {
|
||||
self.creatures.projectiles.remove(i);
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
self.creatures.projectiles.retain(|p| p.id != id);
|
||||
}
|
||||
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
|
||||
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
|
||||
@@ -1593,7 +1730,11 @@ impl Game<'_> {
|
||||
Target::Boat(id) => self.boats.retain(|b| b.id != id),
|
||||
Target::Cart(id) => self.carts.retain(|c| c.id != id),
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
}
|
||||
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
@@ -1601,19 +1742,18 @@ impl Game<'_> {
|
||||
} else {
|
||||
vec![self.implied_player()]
|
||||
};
|
||||
let names: Vec<Text> = targets.iter().map(|&t| self.target_name(t)).collect();
|
||||
// Remove indexed entities from the back so earlier indices stay valid.
|
||||
let mut ordered = targets.clone();
|
||||
ordered.sort_by_key(|t| {
|
||||
std::cmp::Reverse(match t {
|
||||
Target::Orb(i) | Target::Projectile(i) => *i,
|
||||
_ => 0,
|
||||
})
|
||||
});
|
||||
for t in ordered {
|
||||
let mut names: Vec<Text> = Vec::new();
|
||||
for &t in &targets {
|
||||
// The name of an entity in another level is read there.
|
||||
let name = match self.in_target_level(t, |g| g.target_name(t)) {
|
||||
Some(name) => name,
|
||||
None => self.target_name(t),
|
||||
};
|
||||
names.push(name);
|
||||
}
|
||||
for &t in &targets {
|
||||
self.kill_target(t);
|
||||
}
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
let message = if targets.len() == 1 {
|
||||
Text::translate("commands.kill.success.single", &[names[0].clone()])
|
||||
} else {
|
||||
@@ -1705,6 +1845,20 @@ impl Game<'_> {
|
||||
self.spawn_bolt(pos, false);
|
||||
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
|
||||
}
|
||||
"end_crystal" => {
|
||||
// EndCrystal's constructor takes an id from the entity
|
||||
// counter, and the crystal is shown with its base.
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(super::dragon::Crystal {
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(pos[0], pos[1], pos[2]),
|
||||
show_bottom: true,
|
||||
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
name = Text::translate("entity.minecraft.end_crystal", &[]);
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
|
||||
let cart_kind = super::minecarts::CartKind::from_id(&kind)
|
||||
.unwrap_or(super::minecarts::CartKind::Rideable);
|
||||
|
||||
@@ -31,8 +31,13 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
|
||||
impl Game<'_> {
|
||||
// ---- entity data ---------------------------------------------------
|
||||
|
||||
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved.
|
||||
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity
|
||||
/// in another level is read in the level that holds it.
|
||||
pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option<Tag> {
|
||||
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
|
||||
.flatten()
|
||||
}
|
||||
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
|
||||
let player_uuid = self.level.player_uuid;
|
||||
Some(match t {
|
||||
Target::Guest(_) => {
|
||||
@@ -113,9 +118,11 @@ impl Game<'_> {
|
||||
let o = self.others.iter().find(|o| o.id == id)?.clone();
|
||||
self.other_to_nbt(&o, uuid)
|
||||
}
|
||||
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
|
||||
Target::Projectile(i) => {
|
||||
let p = self.creatures.projectiles.get(i)?;
|
||||
Target::Orb(id) => {
|
||||
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
|
||||
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
|
||||
player_uuid
|
||||
} else {
|
||||
@@ -129,6 +136,16 @@ impl Game<'_> {
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
let c = self.crystals.iter().find(|c| c.id == id)?;
|
||||
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
|
||||
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
Target::Dragon(id) => {
|
||||
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
|
||||
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
|
||||
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
|
||||
@@ -397,6 +414,19 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let target = self.data_ref(ctx, 0)?;
|
||||
// An entity in another level is read and written in the level that
|
||||
// holds it.
|
||||
if let DataRef::Entity(t) = &target {
|
||||
let t = *t;
|
||||
let target = target.clone();
|
||||
return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) {
|
||||
Some(r) => r,
|
||||
None => Err(err("argument.entity.notfound.entity", &[])),
|
||||
};
|
||||
}
|
||||
self.cmd_data_in(id, ctx, target)
|
||||
}
|
||||
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
|
||||
match id {
|
||||
"data.merge" => {
|
||||
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
|
||||
|
||||
@@ -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}"), &[])
|
||||
}
|
||||
|
||||
@@ -663,10 +663,11 @@ impl Game<'_> {
|
||||
// TraceableEntity.getOwner: who shot a projectile, who threw an
|
||||
// item, who summoned a vex.
|
||||
Some("origin") => match entity {
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.and_then(|p| match (p.owner?, p.guest) {
|
||||
// A guest's shot: `Who::Player` is them.
|
||||
(horizon_client::mobs::Who::Player, Some(conn))
|
||||
|
||||
@@ -119,6 +119,7 @@ impl Game<'_> {
|
||||
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
|
||||
"sting" => DamageKind::Sting,
|
||||
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
|
||||
"freeze" => DamageKind::Freeze,
|
||||
"in_wall" => DamageKind::InWall,
|
||||
"thorns" => DamageKind::Thorns,
|
||||
_ => DamageKind::Generic,
|
||||
|
||||
@@ -67,6 +67,17 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
|
||||
}
|
||||
// SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is
|
||||
// added as a `MobEffectInstance(effect, duration)` (amplifier 0).
|
||||
if let Some(data) = stack.extra.as_deref() {
|
||||
for (id, duration) in &data.stew_effects {
|
||||
if let Some(def) = horizon_client::brewing::effect(id) {
|
||||
self.add_player_effect(horizon_client::effects::EffectInstance::new(
|
||||
def.id, *duration, 0,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for effect in &consumable.effects {
|
||||
self.apply_consume_effect(effect);
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ impl Game<'_> {
|
||||
slot: usize,
|
||||
stack: Option<ItemStack>,
|
||||
) {
|
||||
if let Some(e) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_mut(&(chest[0], chest[1], chest[2]))
|
||||
let key = (chest[0], chest[1], chest[2]);
|
||||
if let Some(e) = self.world.block_entities.get_mut(&key)
|
||||
&& let Some(s) = e.slots_mut().get_mut(slot)
|
||||
{
|
||||
*s = stack;
|
||||
// TransportItemsBetweenContainers calls Container.setChanged.
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
|
||||
|
||||
@@ -46,10 +46,22 @@ pub(super) struct Creatures {
|
||||
pub projectiles: Vec<Projectile>,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// Explosion packets from a guest's turn, waiting for it to end
|
||||
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
|
||||
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
pub elder_apparition: i32,
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
/// Particles from a guest's turn, waiting for it to end
|
||||
/// (`lan_send_particles`: options, centre, spread, speed, count).
|
||||
pub lan_particles: Vec<(
|
||||
horizon_client::particles::ParticleOptions,
|
||||
[f64; 3],
|
||||
[f64; 3],
|
||||
f64,
|
||||
u32,
|
||||
)>,
|
||||
}
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
@@ -198,6 +210,7 @@ impl Game<'_> {
|
||||
uuid: self.level.player_uuid.unwrap_or_default(),
|
||||
allies: std::sync::Arc::new(self.player_allies()),
|
||||
remote: false,
|
||||
passenger: self.riding.is_some(),
|
||||
})
|
||||
}
|
||||
/// Biome facts at a position for finalizeSpawn and variant rules.
|
||||
@@ -679,6 +692,7 @@ impl Game<'_> {
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
self.tick_apparition();
|
||||
self.guardian_bubbles();
|
||||
self.villager_reputation_tick();
|
||||
self.merchant_tick();
|
||||
self.villager_pickups();
|
||||
@@ -928,11 +942,29 @@ impl Game<'_> {
|
||||
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
/// Adds a projectile, giving it an id from the entity counter
|
||||
/// (`Entity.id`, the same counter as the rest) when it has none.
|
||||
pub(super) fn add_projectile(&mut self, mut p: horizon_client::projectiles::Projectile) {
|
||||
if p.id == 0 {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
for e in events {
|
||||
match e {
|
||||
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
|
||||
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
|
||||
MobEvent::Drown { mob, at } => {
|
||||
self.lan_entity_event(mob as i32, super::combat::DROWN_EVENT, at);
|
||||
let v = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == mob)
|
||||
.map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]);
|
||||
self.drown_particles(at, v);
|
||||
}
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
self.audio
|
||||
@@ -1134,7 +1166,7 @@ impl Game<'_> {
|
||||
let v = p.velocity;
|
||||
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
self.add_projectile(p);
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
}
|
||||
MobEvent::Spawn(mut m) => {
|
||||
@@ -1935,6 +1967,7 @@ impl Game<'_> {
|
||||
let mut hits = Vec::new();
|
||||
let mut deflections: Vec<[f64; 3]> = Vec::new();
|
||||
let mut arrow_events = Vec::new();
|
||||
let mut trails = Vec::new();
|
||||
{
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
@@ -2026,6 +2059,10 @@ impl Game<'_> {
|
||||
if shooter.is_some() {
|
||||
p.owner = Some(Who::Player);
|
||||
}
|
||||
// AbstractHurtingProjectile.tick ends with createParticleTrail.
|
||||
if super::remote_projectiles::has_smoke_trail(&p.kind) {
|
||||
trails.push(p.position);
|
||||
}
|
||||
if let Some(a) = p.arrow.as_mut() {
|
||||
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
|
||||
}
|
||||
@@ -2068,6 +2105,7 @@ impl Game<'_> {
|
||||
keep
|
||||
});
|
||||
}
|
||||
self.smoke_trails(trails);
|
||||
for at in deflections {
|
||||
self.audio
|
||||
.play("minecraft:entity.breeze.deflect", Some(at), 1.0, 1.0);
|
||||
@@ -2100,6 +2138,7 @@ impl Game<'_> {
|
||||
_ => self.projectile_hit(&p, hit)?,
|
||||
}
|
||||
}
|
||||
self.flush_lan_explosions();
|
||||
self.flush_wind_bursts()
|
||||
}
|
||||
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
|
||||
@@ -2232,11 +2271,9 @@ impl Game<'_> {
|
||||
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
|
||||
}
|
||||
}
|
||||
/// `Level.isRainingAt` for arrows: raining, open to the sky (the biome's
|
||||
/// precipitation is not checked here).
|
||||
/// `Level.isRainingAt` for arrows.
|
||||
fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
|
||||
let raining = self.world.weather.levels(1.0).0 > 0.2;
|
||||
move |p: BlockPos| raining && self.world.light(p.x, p.y, p.z) >> 4 == 15
|
||||
move |p: BlockPos| self.raining_at(p)
|
||||
}
|
||||
/// The level as arrows see it.
|
||||
fn arrow_scene<'s>(
|
||||
@@ -2274,7 +2311,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if keep {
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
}
|
||||
}
|
||||
/// `TargetBlock.onProjectileHit`: a target block takes its power from
|
||||
@@ -2868,6 +2905,14 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.interact_armadillo(index, &item) {
|
||||
if let Some(stack) = &held {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.interact_copper_golem(index, &item) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -2923,6 +2968,16 @@ impl Game<'_> {
|
||||
consume = true;
|
||||
sound = Some("entity.cow.milk");
|
||||
}
|
||||
// Goat.mobInteract: a bucket on an adult gives milk.
|
||||
Species::Goat if item == "minecraft:bucket" && !m.is_baby() => {
|
||||
give = Some("minecraft:milk_bucket");
|
||||
consume = true;
|
||||
sound = Some(if m.extra.screaming {
|
||||
"entity.goat.screaming.milk"
|
||||
} else {
|
||||
"entity.goat.milk"
|
||||
});
|
||||
}
|
||||
Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => {
|
||||
give = Some("minecraft:mushroom_stew");
|
||||
consume = true;
|
||||
@@ -3183,6 +3238,19 @@ impl Game<'_> {
|
||||
self.trigger_entity("minecraft:tame_animal", "entity", &view);
|
||||
}
|
||||
}
|
||||
// Tadpole.feed: frog food ages it by a tenth of the time left to
|
||||
// grow into a frog (it grows on its next tick once past 24000).
|
||||
Species::Tadpole if is_food && !m.extra.age_locked => {
|
||||
consume = true;
|
||||
let left = 24_000 - m.extra.age;
|
||||
m.extra.age += ((left / 20) as f32 * 0.1) as i32 * 20;
|
||||
// One happy villager particle at rest about the body.
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
"happy_villager",
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
);
|
||||
}
|
||||
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
|
||||
_ if is_food
|
||||
&& m.kind.flags.animal
|
||||
@@ -3270,6 +3338,50 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `Armadillo.mobInteract`: a brush brushes a scute off an adult
|
||||
/// (`brushOffScute`) and loses 16 durability.
|
||||
fn interact_armadillo(&mut self, index: usize, item: &str) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
if item != "minecraft:brush"
|
||||
|| m.species() != Species::Armadillo
|
||||
|| !m.alive()
|
||||
|| m.is_baby()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (id, at) = (m.id, m.position);
|
||||
let feet = [at.x, at.y, at.z];
|
||||
let tool = self.held().cloned();
|
||||
let table = "minecraft:brush/armadillo";
|
||||
for d in self.sequence_loot(table, table, tool.as_ref()) {
|
||||
self.spawn_at_location(at, 0.0, d);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armadillo.brush", Some(feet), 1.0, 1.0);
|
||||
let ctx = self.who_context(Some(Who::Mob(id)), None);
|
||||
self.post_game_event("minecraft:entity_interact", feet, ctx);
|
||||
// ItemStack.hurtAndBreak(16).
|
||||
if !self.creative
|
||||
&& let Some(stack) = tool
|
||||
{
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage);
|
||||
if max > 0 {
|
||||
let loss = self.durability_loss(&stack, 16);
|
||||
let slot = self.held_slot();
|
||||
if let Some(held) = self.stack_in_mut(slot).as_mut() {
|
||||
held.damage += loss;
|
||||
if held.damage >= max {
|
||||
*self.stack_in_mut(slot) = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(super) fn damage_held_tool(&mut self) {
|
||||
if self.creative {
|
||||
return;
|
||||
@@ -3324,7 +3436,7 @@ impl Game<'_> {
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
self.creatures.projectiles.push(proj);
|
||||
self.add_projectile(proj);
|
||||
self.player_projectile_shot();
|
||||
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
|
||||
// second is the `use_cooldown` component, as for any thrown item.
|
||||
@@ -3417,6 +3529,60 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Guardian.aiStep` on a client: two bubbles a tick trailing a guardian
|
||||
/// that swims along, and bubbles up the beam to its target. The server
|
||||
/// never draws them, so the mob's own random stays untouched.
|
||||
pub(super) fn guardian_bubbles(&mut self) {
|
||||
use horizon_client::mobs::guardian;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let mut swimming = Vec::new();
|
||||
let mut beams = Vec::new();
|
||||
for m in &self.mobs {
|
||||
if !m.alive() || !matches!(m.species(), Species::Guardian | Species::ElderGuardian) {
|
||||
continue;
|
||||
}
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if m.extra.flag2 && m.fluids.in_water {
|
||||
let (width, height, _) = m.dims();
|
||||
swimming.push((at, width, height, m.view_vector()));
|
||||
}
|
||||
if let Some((_, scale)) = guardian::beam(m, 0.0)
|
||||
&& let Some(t) = self.beam_target(m.ai.target, 1.0)
|
||||
{
|
||||
beams.push(([at[0], m.eye_y(), at[2]], [t.x, t.y, t.z], f64::from(scale)));
|
||||
}
|
||||
}
|
||||
let bubble = ParticleOptions::simple(ParticleType::Bubble);
|
||||
for (at, width, height, view) in swimming {
|
||||
for _ in 0..2 {
|
||||
let (x, y, z) = {
|
||||
let r = self.particles.random();
|
||||
(r.next_double(), r.next_double(), r.next_double())
|
||||
};
|
||||
let p = [
|
||||
at[0] + width * (2.0 * x - 1.0) * 0.5 - view[0] * 1.5,
|
||||
at[1] + height * y - view[1] * 1.5,
|
||||
at[2] + width * (2.0 * z - 1.0) * 0.5 - view[2] * 1.5,
|
||||
];
|
||||
self.particles.add(&bubble, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
for (eye, target, scale) in beams {
|
||||
let d = [target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]];
|
||||
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if length <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut dist = self.particles.random().next_double();
|
||||
while dist < length {
|
||||
let r = self.particles.random();
|
||||
dist += 1.8 - scale + r.next_double() * (1.7 - scale);
|
||||
let f = dist / length;
|
||||
let p = [eye[0] + d[0] * f, eye[1] + d[1] * f, eye[2] + d[2] * f];
|
||||
self.particles.add(&bubble, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Mob models and projectiles for the entity pass.
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -3725,6 +3891,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::InWall => "minecraft:in_wall",
|
||||
K::Thorns => "minecraft:thorns",
|
||||
K::SonicBoom => "minecraft:sonic_boom",
|
||||
K::Freeze => "minecraft:freeze",
|
||||
K::OutOfWorld => "minecraft:out_of_world",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ impl Game<'_> {
|
||||
a.pierce = pierce;
|
||||
}
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
}
|
||||
self.player_projectile_shot();
|
||||
let pitch = if index == 0 {
|
||||
|
||||
@@ -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`.
|
||||
@@ -766,7 +766,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 +797,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 +982,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,
|
||||
)
|
||||
}
|
||||
@@ -120,6 +120,11 @@ impl Game<'_> {
|
||||
p.guest = guest;
|
||||
// Not into the one who turned it on its way out.
|
||||
p.left_owner = false;
|
||||
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
|
||||
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
|
||||
// at once, not at the fireball's next update ten ticks later.
|
||||
p.acceleration = 0.1;
|
||||
p.needs_sync = true;
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
let from = self
|
||||
|
||||
@@ -1034,7 +1034,7 @@ impl Game<'_> {
|
||||
a.pickup = horizon_client::arrows::Pickup::Allowed;
|
||||
}
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.creatures.projectiles.push(shot);
|
||||
self.add_projectile(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,11 +291,11 @@ pub(super) enum FightWho {
|
||||
}
|
||||
/// A player in the End as the dragon sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FightPlayer {
|
||||
who: FightWho,
|
||||
position: Vec3,
|
||||
bounds: Aabb,
|
||||
alive: bool,
|
||||
pub(super) struct FightPlayer {
|
||||
pub(super) who: FightWho,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) bounds: Aabb,
|
||||
pub(super) alive: bool,
|
||||
creative: bool,
|
||||
spectator: bool,
|
||||
}
|
||||
@@ -693,7 +693,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The players in the level the dragon sees (`level.players()`): the
|
||||
/// window's own, then every guest in the level.
|
||||
fn fight_players(&self) -> Vec<FightPlayer> {
|
||||
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
out.push(FightPlayer {
|
||||
@@ -722,7 +722,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
|
||||
/// sent to its client).
|
||||
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
|
||||
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
|
||||
match who {
|
||||
FightWho::Host => f(self),
|
||||
FightWho::Guest(conn) => {
|
||||
@@ -828,8 +828,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
// EndCrystal's constructor takes an id from the entity counter.
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
show_bottom: true,
|
||||
time,
|
||||
@@ -1566,6 +1568,7 @@ impl Game<'_> {
|
||||
age: 0,
|
||||
duration: 200,
|
||||
from_dragon: true,
|
||||
potion: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2078,12 +2081,18 @@ impl Game<'_> {
|
||||
age: 0,
|
||||
duration: 600,
|
||||
from_dragon: false,
|
||||
potion: None,
|
||||
});
|
||||
self.audio.play(
|
||||
"minecraft:entity.dragon_fireball.explode",
|
||||
Some([next.x, next.y, next.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
// DragonFireball.onHit: level event 2006 (the burst of dragon
|
||||
// breath and the explosion sound) at its block position.
|
||||
self.level_event_at(
|
||||
2006,
|
||||
[
|
||||
next.x.floor() as i32,
|
||||
next.y.floor() as i32,
|
||||
next.z.floor() as i32,
|
||||
],
|
||||
1,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -2110,6 +2119,10 @@ impl Game<'_> {
|
||||
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
|
||||
let mut clouds = std::mem::take(&mut self.breath_clouds);
|
||||
clouds.retain_mut(|c| {
|
||||
// Lingering potions' clouds tick with the potions.
|
||||
if c.potion.is_some() {
|
||||
return true;
|
||||
}
|
||||
c.age += 1;
|
||||
if c.age >= c.duration {
|
||||
return false;
|
||||
@@ -2257,8 +2270,22 @@ pub(super) struct Breath {
|
||||
pub(super) at: [f64; 3],
|
||||
pub(super) radius: f32,
|
||||
pub(super) age: i32,
|
||||
duration: i32,
|
||||
from_dragon: bool,
|
||||
pub(super) duration: i32,
|
||||
pub(super) from_dragon: bool,
|
||||
/// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies.
|
||||
pub(super) potion: Option<super::potions::PotionCloud>,
|
||||
}
|
||||
impl Breath {
|
||||
/// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a
|
||||
/// lingering potion's).
|
||||
pub(super) fn waiting(&self) -> bool {
|
||||
self.age
|
||||
< if self.potion.is_some() {
|
||||
super::potions::LINGERING_WAIT
|
||||
} else {
|
||||
20
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
|
||||
@@ -2835,8 +2862,11 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
// EndCrystal's constructor takes an id from the entity counter, so
|
||||
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(x + 0.5, y, z + 0.5),
|
||||
show_bottom: false,
|
||||
time,
|
||||
@@ -2853,6 +2883,21 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast
|
||||
/// (`Entity.kill` does not hurt it).
|
||||
pub(super) fn kill_crystal(&mut self, id: u32) {
|
||||
if let Some(i) = self.crystals.iter().position(|c| c.id == id) {
|
||||
self.destroy_crystal(i, None, CrystalDamage::Explosion);
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
|
||||
/// does not hurt it (the dragon flies its death flight when its health
|
||||
/// runs out).
|
||||
pub(super) fn kill_dragon(&mut self, id: u32) {
|
||||
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
|
||||
self.dragon = None;
|
||||
}
|
||||
}
|
||||
/// Any entity touching `room` (`Level.getEntities(null, box)`).
|
||||
fn entity_in(&self, room: Aabb) -> bool {
|
||||
let touches = |lo: Vec3, hi: Vec3| {
|
||||
|
||||
@@ -253,10 +253,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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,8 +30,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,
|
||||
@@ -433,6 +433,8 @@ impl Game<'_> {
|
||||
let options = ParticleOptions::simple(kind);
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
// The players a server serves are sent them too.
|
||||
self.lan_send_particles(&options, at, spread, speed, count);
|
||||
}
|
||||
/// `FishingHook.tick`.
|
||||
pub(super) fn tick_fishing(&mut self) {
|
||||
@@ -770,8 +772,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 {
|
||||
|
||||
@@ -457,7 +457,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.stats.custom("play_record", 1);
|
||||
self.world.block_entities.insert(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
BlockEntity::Jukebox(Jukebox {
|
||||
|
||||
@@ -412,6 +412,26 @@ fn mob_meta(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
|
||||
/// off hand, feet, legs, chest, head, body, saddle).
|
||||
fn mob_equipment(
|
||||
@@ -420,13 +440,16 @@ 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 {
|
||||
@@ -930,6 +953,7 @@ impl Game<'_> {
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, player_entity, &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;
|
||||
@@ -1006,6 +1030,9 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
}
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.needs_sync = false;
|
||||
}
|
||||
// `runBlockEvents`' broadcast precedes the tick's block changes
|
||||
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
|
||||
// guest re-runs a piston's `triggerEvent` while its level still
|
||||
@@ -1166,8 +1193,11 @@ impl Game<'_> {
|
||||
drop(holding);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
// A mace's wind burst from a guest's attack.
|
||||
// A blast from a guest's attack, and a mace's wind burst.
|
||||
self.flush_lan_explosions();
|
||||
let _ = self.flush_wind_bursts();
|
||||
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
|
||||
self.flush_lan_particles();
|
||||
}
|
||||
|
||||
fn lan_guest_joined(
|
||||
@@ -1521,6 +1551,7 @@ 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;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
|
||||
@@ -267,8 +267,10 @@ impl Game<'_> {
|
||||
}
|
||||
let mut play = host.play.remove(&conn).unwrap_or_default();
|
||||
if fresh {
|
||||
// A new body (joined, respawned, changed level): nothing on it yet.
|
||||
// A new body (joined, respawned, changed level): nothing on it yet,
|
||||
// its effects' modifiers and armor protection included.
|
||||
play.equipped = None;
|
||||
play.potions = Default::default();
|
||||
play.sculk = Default::default();
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
@@ -405,6 +407,12 @@ impl Game<'_> {
|
||||
self.apply_equipment();
|
||||
play.equipped = Some(equipped);
|
||||
}
|
||||
// The player's effects are on the new body before anything it does
|
||||
// (the movement that brought it, a landing): vanilla keeps the same
|
||||
// entity through a level change, with its attributes and effects.
|
||||
if fresh {
|
||||
self.apply_effect_status();
|
||||
}
|
||||
self.update_totem_ready();
|
||||
Some(GuestSwap {
|
||||
conn,
|
||||
@@ -1216,6 +1224,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);
|
||||
@@ -1540,9 +1549,13 @@ impl Game<'_> {
|
||||
if let Some(victim) = Self::pvp_victim(host, conn, id) {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
g.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1847,10 +1860,6 @@ impl Game<'_> {
|
||||
};
|
||||
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
|
||||
// A new body: nothing given to the old one carries over.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.potions = Default::default();
|
||||
}
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
@@ -2209,7 +2218,7 @@ impl Game<'_> {
|
||||
}
|
||||
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);
|
||||
@@ -2278,6 +2287,7 @@ 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;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.update_soul_speed_block();
|
||||
@@ -2294,6 +2304,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) {
|
||||
@@ -2332,6 +2344,13 @@ impl Game<'_> {
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
let velocity = marked.then_some(player.velocity);
|
||||
// Player.causeFallDamage: a fall of 2 blocks or more counts.
|
||||
for event in &events {
|
||||
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
|
||||
{
|
||||
game.stats.custom("fall_one_cm", *cm);
|
||||
}
|
||||
}
|
||||
game.record_combat(&events);
|
||||
game.wear_armor(&events);
|
||||
(events, velocity)
|
||||
@@ -2354,6 +2373,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.
|
||||
@@ -2689,8 +2717,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();
|
||||
|
||||
@@ -342,6 +342,7 @@ impl Game<'_> {
|
||||
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
|
||||
e.pitch = p.pitch;
|
||||
e.velocity = at(p.velocity);
|
||||
e.needs_sync = p.needs_sync;
|
||||
// Projectile.getAddEntityPacket: the owner's id.
|
||||
e.data = match p.guest.and_then(|conn| hub.guest(conn)) {
|
||||
Some(g) => g.entity_id,
|
||||
@@ -466,7 +467,7 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
|
||||
// The dragon's breath and lingering potion 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(
|
||||
@@ -484,10 +485,14 @@ impl Game<'_> {
|
||||
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
|
||||
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
|
||||
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
|
||||
if b.age < 20 {
|
||||
if b.waiting() {
|
||||
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if let Some(p) = &breath {
|
||||
let particle = match &b.potion {
|
||||
Some(p) => self.network_particle(&p.particle(), None),
|
||||
None => breath.clone(),
|
||||
};
|
||||
if let Some(p) = &particle {
|
||||
e.meta
|
||||
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
|
||||
}
|
||||
|
||||
@@ -175,9 +175,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 +319,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 +336,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)]
|
||||
@@ -374,6 +369,7 @@ mod tests {
|
||||
uuid: [0; 4],
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
passenger: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -318,16 +318,86 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles` for the players a server serves: one
|
||||
/// `ClientboundLevelParticlesPacket` to each player in the level whose
|
||||
/// block is within 32 blocks of `at`. The window's own player is not
|
||||
/// sent it (the caller draws it in this window). While the host holds
|
||||
/// the players (a guest's turn through `as_guest`, a host's hit on a
|
||||
/// guest) the packet waits for the turn to end so that it reaches every
|
||||
/// player, the guest too.
|
||||
pub(super) fn lan_send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
if self.lan.is_none() {
|
||||
// The server is running (its sounds are captured for the guests).
|
||||
if self.audio.capture.is_some() {
|
||||
self.creatures
|
||||
.lan_particles
|
||||
.push((options.clone(), at, spread, speed, count));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(particle) = self.network_particle(options, None) else {
|
||||
return;
|
||||
};
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let body = 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,
|
||||
})
|
||||
.encode();
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
// BlockPos.closerToCenterThan(at, 32): from the centre of the
|
||||
// player's block.
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if d < 32.0 * 32.0 {
|
||||
g.sender.send(body.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The particles guests' turns put off.
|
||||
pub(super) fn flush_lan_particles(&mut self) {
|
||||
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
|
||||
{
|
||||
self.lan_send_particles(&options, at, spread, speed, count);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
|
||||
/// sound and the push reach them on their own (the level's sounds, the
|
||||
/// motion a hurt player is sent), so the packet carries neither.
|
||||
pub(super) fn lan_explode(
|
||||
&self,
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
small: bool,
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
) {
|
||||
// In a guest's turn (their hit on an end crystal or a minecart) the
|
||||
// host holds the players (`as_guest`): the packet waits for the turn
|
||||
// to end so that it reaches every player, the guest too.
|
||||
if self.lan_acting.is_some() && self.lan.is_none() {
|
||||
self.creatures
|
||||
.lan_explosions
|
||||
.push((center, small, radius, block_count));
|
||||
return;
|
||||
}
|
||||
let emitter = if small {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
@@ -341,6 +411,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The explosions guests' turns put off.
|
||||
pub(super) fn flush_lan_explosions(&mut self) {
|
||||
for (center, small, radius, block_count) in
|
||||
std::mem::take(&mut self.creatures.lan_explosions)
|
||||
{
|
||||
self.lan_explode(center, small, radius, block_count);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
|
||||
/// no block particles, and for each player it pushed the push
|
||||
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
|
||||
|
||||
@@ -86,9 +86,11 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(strike) = self.pvp_strike()
|
||||
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
self.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
true
|
||||
@@ -106,8 +108,18 @@ impl Game<'_> {
|
||||
.map(|g| Victim::Guest(g.conn))
|
||||
}
|
||||
|
||||
/// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker
|
||||
/// (the host, or a guest swapped in) only once the hit hurt the victim.
|
||||
pub(super) fn pvp_exhaustion(&mut self) {
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The attacker's side of `Player.attack` (the host, or a guest swapped
|
||||
/// in): the damage, the charge reset, the exhaustion and the swing.
|
||||
/// in): the damage, the charge reset and the swing.
|
||||
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
|
||||
let p = self.player.as_ref()?;
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
@@ -116,11 +128,6 @@ impl Game<'_> {
|
||||
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
self.attack_ticks = 0;
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
self.swing_arm();
|
||||
Some(Strike {
|
||||
attacker: self.lan_acting,
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -277,7 +277,12 @@ impl Game<'_> {
|
||||
g.carried = menu.carried.take();
|
||||
}
|
||||
self.register_exploration_maps(made);
|
||||
gm.merchant.session = self.merchant_apply(session, gm.merchant.mob);
|
||||
let (session, traded) = self.merchant_apply(session, gm.merchant.mob);
|
||||
gm.merchant.session = session;
|
||||
if !traded.is_empty() {
|
||||
// The guest's own statistic and advancements.
|
||||
self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded));
|
||||
}
|
||||
self.drop_guest_items(host, gi, dropped);
|
||||
self.lan_send_offers(host, conn, &mut gm, false);
|
||||
self.lan_send_merchant_content(host, conn, &mut gm);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! 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).
|
||||
//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon
|
||||
//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009.
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
@@ -72,24 +74,42 @@ impl Game<'_> {
|
||||
} else {
|
||||
// ParticleUtils.spawnParticleInBlock: up to the
|
||||
// block's shape (a full block when air).
|
||||
let inside = self.world.block(at[0], at[1], at[2]);
|
||||
let height = if facts.is_air(inside) {
|
||||
1.0
|
||||
} else {
|
||||
facts
|
||||
.get(inside)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| b[4])
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
(at, count, 0.5, height, true)
|
||||
(at, count, 0.5, self.block_shape_top(at), true)
|
||||
}
|
||||
}
|
||||
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
|
||||
None => return,
|
||||
};
|
||||
let options = ParticleOptions::simple(ParticleType::HappyVillager);
|
||||
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
|
||||
}
|
||||
/// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air.
|
||||
fn block_shape_top(&self, at: [i32; 3]) -> f64 {
|
||||
let facts = self.res.facts();
|
||||
let inside = self.world.block(at[0], at[1], at[2]);
|
||||
if facts.is_air(inside) {
|
||||
1.0
|
||||
} else {
|
||||
facts
|
||||
.get(inside)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| b[4])
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
}
|
||||
/// `ParticleUtils.spawnParticles`: `count` slow particles spread over
|
||||
/// `xz` blocks around `at` and `height` up, only above solid ground
|
||||
/// unless floating ones are allowed.
|
||||
fn spawn_particles(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
count: i32,
|
||||
xz: f64,
|
||||
height: f64,
|
||||
floating: bool,
|
||||
ty: ParticleType,
|
||||
) {
|
||||
let options = ParticleOptions::simple(ty);
|
||||
for _ in 0..count.max(0) {
|
||||
let r = self.particles.level_random();
|
||||
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
|
||||
@@ -108,6 +128,161 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 2012 (a turtle egg laid on sand):
|
||||
/// `ParticleUtils.spawnParticleInBlock` with happy villager sparks.
|
||||
pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) {
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
let height = self.block_shape_top(at);
|
||||
self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager);
|
||||
}
|
||||
/// `LevelEventHandler` 3009 (a sniffer egg cracking on moss):
|
||||
/// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`.
|
||||
pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) {
|
||||
let mut spawned = Vec::new();
|
||||
particles::on_block_faces_between(
|
||||
self.particles.level_random(),
|
||||
[pos.x, pos.y, pos.z],
|
||||
3,
|
||||
6,
|
||||
|p, v| spawned.push((p, v)),
|
||||
);
|
||||
let options = ParticleOptions::simple(ParticleType::EggCrack);
|
||||
for (p, v) in spawned {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried):
|
||||
/// eight puffs just above the block, drawn after the sound.
|
||||
pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) {
|
||||
let options = ParticleOptions::simple(ty);
|
||||
for _ in 0..8 {
|
||||
let r = self.particles.level_random();
|
||||
let x = f64::from(pos.x) + r.next_double();
|
||||
let z = f64::from(pos.z) + r.next_double();
|
||||
self.particles
|
||||
.add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke
|
||||
/// puffs inside the block, drawn after the sound.
|
||||
pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) {
|
||||
let options = ParticleOptions::simple(ParticleType::Smoke);
|
||||
for _ in 0..5 {
|
||||
let r = self.particles.level_random();
|
||||
let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2;
|
||||
let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2;
|
||||
let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2;
|
||||
self.particles.add(&options, [x, y, z], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath
|
||||
/// particles thrown outwards, then the explosion sound when `data` is 1.
|
||||
pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) {
|
||||
for _ in 0..200 {
|
||||
let r = self.particles.level_random();
|
||||
let dist = r.next_float() * 4.0;
|
||||
let angle = r.next_float() * std::f32::consts::TAU;
|
||||
let vx = f64::from(trig::cos(f64::from(angle)) * dist);
|
||||
let vy = 0.01 + r.next_double() * 0.5;
|
||||
let vz = f64::from(trig::sin(f64::from(angle)) * dist);
|
||||
let options = ParticleOptions {
|
||||
scale: dist,
|
||||
..ParticleOptions::simple(ParticleType::DragonBreath)
|
||||
};
|
||||
self.particles.add(
|
||||
&options,
|
||||
[
|
||||
f64::from(pos.x) + vx * 0.1,
|
||||
f64::from(pos.y) + 0.3,
|
||||
f64::from(pos.z) + vz * 0.1,
|
||||
],
|
||||
[vx, vy, vz],
|
||||
);
|
||||
}
|
||||
if data == 1 {
|
||||
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio.play(
|
||||
"minecraft:entity.dragon_fireball.explode",
|
||||
Some(at),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down.
|
||||
fn is_stalactite(&self, state: BlockStateId) -> bool {
|
||||
self.res.facts().get(state).has_class("SpeleothemBlock")
|
||||
&& self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("vertical_direction"))
|
||||
.is_some_and(|d| d == "down")
|
||||
}
|
||||
/// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at
|
||||
/// the tip `pos`. The fluid above the stalactite's root picks the drip;
|
||||
/// with none, the dimension's default (lava in the Nether).
|
||||
pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) {
|
||||
let tip_state = self.world.block(pos.x, pos.y, pos.z);
|
||||
if !self.is_stalactite(tip_state) {
|
||||
return;
|
||||
}
|
||||
// findRootBlock: up through the same stalactite, at most 11 steps.
|
||||
let tip_name = self.res.facts().get(tip_state).name.clone();
|
||||
let mut root = None;
|
||||
for step in 1..11 {
|
||||
let y = pos.y + step;
|
||||
if !(horizon_client::chunk::MIN_Y
|
||||
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
|
||||
.contains(&y)
|
||||
{
|
||||
break;
|
||||
}
|
||||
let state = self.world.block(pos.x, y, pos.z);
|
||||
if self.res.facts().get(state).name != tip_name {
|
||||
root = Some(y);
|
||||
break;
|
||||
}
|
||||
if !self.is_stalactite(state) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(root_y) = root else {
|
||||
return;
|
||||
};
|
||||
let above = self.world.block(pos.x, root_y + 1, pos.z);
|
||||
let above_facts = self.res.facts().get(above);
|
||||
let nether = self.dim == super::Dim::Nether;
|
||||
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
|
||||
horizon_client::blocks::Fluid::Water
|
||||
} else {
|
||||
above_facts.fluid
|
||||
};
|
||||
let ty = match fluid {
|
||||
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
|
||||
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
|
||||
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
|
||||
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
|
||||
};
|
||||
let offset = self
|
||||
.pack
|
||||
.context
|
||||
.tables
|
||||
.offset(tip_state, pos.x, pos.z)
|
||||
.unwrap_or([0.0; 3]);
|
||||
// The tip shape starts 5/16 up; the particle sits a pixel below that.
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ty),
|
||||
[
|
||||
f64::from(pos.x) + 0.5 + offset[0],
|
||||
f64::from(pos.y) + 5.0 / 16.0 - 0.0625,
|
||||
f64::from(pos.z) + 0.5 + offset[2],
|
||||
],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
|
||||
/// (an end portal opening, the dragon's death roar): here, and sent to
|
||||
/// every guest whatever their dimension.
|
||||
|
||||
@@ -120,6 +120,10 @@ impl Levels {
|
||||
.iter_mut()
|
||||
.map(|(d, p)| (*d, &mut p.world.chunks))
|
||||
}
|
||||
/// The levels that are not in place, by dimension.
|
||||
pub(super) fn parked_dims(&self) -> Vec<Dim> {
|
||||
self.parked.keys().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -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> {
|
||||
@@ -1114,9 +1121,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 +1168,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 +1201,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 +1270,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if has_data {
|
||||
self.map_vertices(id, m, light, out);
|
||||
self.map_vertices(id, false, m, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
items::{ItemStack, Items},
|
||||
mesher::Vertex,
|
||||
mobs::{Part, anim::Poses},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
@@ -201,6 +201,17 @@ impl Minecart {
|
||||
fn horizontal(&self) -> f64 {
|
||||
self.velocity.x.hypot(self.velocity.z)
|
||||
}
|
||||
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
|
||||
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
let mut total = 0.0f32;
|
||||
for s in slots.iter().flatten() {
|
||||
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
|
||||
total += s.count as f32 / max as f32;
|
||||
}
|
||||
total /= slots.len().max(1) as f32;
|
||||
// Mth.lerpDiscrete(total, 0, 15)
|
||||
(total * 14.0).floor() as i32 + i32::from(total > 0.0)
|
||||
}
|
||||
}
|
||||
pub(super) struct CartModels {
|
||||
pub models: HashMap<CartKind, Part>,
|
||||
@@ -717,12 +728,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 {
|
||||
@@ -950,7 +956,7 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top.
|
||||
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
|
||||
pub(super) fn dismount_cart(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
let c = &self.carts[i];
|
||||
@@ -960,17 +966,25 @@ impl Game<'_> {
|
||||
c.position.z.floor() as i32,
|
||||
);
|
||||
let mut target = None;
|
||||
// DismountHelper.offsetsForDirection of the cart's facing
|
||||
// (Direction.fromYRot of its yaw): right, left, back right, back left,
|
||||
// forward right, forward left, back, forward.
|
||||
let step = |q: i32| [(0, 1), (-1, 0), (0, -1), (1, 0)][(q & 3) as usize];
|
||||
let facing = (f64::from(c.yaw) / 90.0 + 0.5).floor() as i32;
|
||||
let (fx, fz) = step(facing);
|
||||
let (rx, rz) = (-fz, fx);
|
||||
let offsets = [
|
||||
(rx, rz),
|
||||
(-rx, -rz),
|
||||
(rx - fx, rz - fz),
|
||||
(-rx - fx, -rz - fz),
|
||||
(fx + rx, fz + rz),
|
||||
(fx - rx, fz - rz),
|
||||
(-fx, -fz),
|
||||
(fx, fz),
|
||||
];
|
||||
'search: for dy in [0, 1, -1] {
|
||||
for (dx, dz) in [
|
||||
(1, 0),
|
||||
(-1, 0),
|
||||
(0, 1),
|
||||
(0, -1),
|
||||
(1, 1),
|
||||
(-1, -1),
|
||||
(1, -1),
|
||||
(-1, 1),
|
||||
] {
|
||||
for (dx, dz) in offsets {
|
||||
let (x, y, z) = (bx + dx, by + dy, bz + dz);
|
||||
let below = self.res.facts().get(self.world.block(x, y - 1, z));
|
||||
let here = self.res.facts().get(self.world.block(x, y, z));
|
||||
@@ -1054,6 +1068,22 @@ impl Game<'_> {
|
||||
}
|
||||
best
|
||||
}
|
||||
/// Each cart's kind and comparator signal, for the detector rail
|
||||
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
|
||||
/// counts the items in that menu.
|
||||
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
|
||||
self.carts
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
let slots = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Chest(open)) => open,
|
||||
_ => &c.items,
|
||||
};
|
||||
(c.kind, Minecart::container_signal(slots, self.res.items()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
|
||||
pub(super) fn interact_cart(&mut self) -> bool {
|
||||
let Some((i, d)) = self.cart_hit() else {
|
||||
@@ -1071,10 +1101,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.riding = Some(self.carts[i].id);
|
||||
}
|
||||
// AbstractMinecartContainer.interact: no sneak check.
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
if sneaking {
|
||||
return false;
|
||||
}
|
||||
let c = self.carts[i].clone();
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
self.world
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
//! Resource pack selection bound to the game: the pack screen lists, and a
|
||||
//! full resource reload after a change, while playing.
|
||||
use super::Game;
|
||||
use super::{
|
||||
Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures,
|
||||
};
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
gfx::PreparedTextures,
|
||||
hud::HudAtlas,
|
||||
lang,
|
||||
pack_screen::{self, Compat, PackInfo, Source as PackSource},
|
||||
packs::{self, Pack, Position},
|
||||
streaming::WorkerContext,
|
||||
text::{Style, Text},
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// Where overlay roots are built (beside options.txt).
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
@@ -98,7 +107,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
|
||||
infos
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn vanilla_root(&self) -> PathBuf {
|
||||
pub(super) fn vanilla_root(&self) -> PathBuf {
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.options.resources.clone()
|
||||
} else {
|
||||
@@ -138,35 +147,36 @@ impl Game<'_> {
|
||||
self.ui.options.resource_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
/// Reload from the selected packs and the server's.
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
/// The CPU half of the reload, safe on a worker (`PackReload` runs it):
|
||||
/// `apply_staged_pack` finishes the GPU half and the swap.
|
||||
pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result<StagedPack> {
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let server = &self.server_packs.active;
|
||||
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
|
||||
let root =
|
||||
super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods);
|
||||
self.reload_resources(&root)
|
||||
stage_pack(
|
||||
&vanilla,
|
||||
&self.settings.resource_packs,
|
||||
&self.server_packs.active,
|
||||
&self.settings.mods.enabled,
|
||||
&self.options.dev_mods,
|
||||
&self.pack.assets,
|
||||
&self.res,
|
||||
&self.pack.context,
|
||||
self.settings.force_unicode,
|
||||
self.settings.japanese_glyphs,
|
||||
&super::Progress::new(),
|
||||
)
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
|
||||
atlas.font.uniform = self.settings.force_unicode;
|
||||
atlas.font.japanese = self.settings.japanese_glyphs;
|
||||
let gui = super::UiResources {
|
||||
atlas,
|
||||
panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
|
||||
};
|
||||
let pack = super::PackResources::load(
|
||||
/// The render-thread half: the renderer on the new pack's textures, then
|
||||
/// the swap (`ReloadInstance`'s application listeners).
|
||||
pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> {
|
||||
let StagedPack {
|
||||
root,
|
||||
self.pack.assets.clone(),
|
||||
render,
|
||||
self.res.facts(),
|
||||
self.pack.context.biomes.clone(),
|
||||
self.pack.context.light.clone(),
|
||||
)?;
|
||||
gui,
|
||||
pack,
|
||||
textures,
|
||||
} = staged;
|
||||
// Keep the working GPU resources, and hand its window surface over.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
@@ -174,7 +184,17 @@ impl Game<'_> {
|
||||
let renderer = match self
|
||||
.window
|
||||
.clone()
|
||||
.map(|window| self.renderer_for(window, &pack, &gui))
|
||||
.map(|window| {
|
||||
build_renderer(
|
||||
window,
|
||||
self.options.offscreen,
|
||||
&pack,
|
||||
&gui,
|
||||
self.renderer.as_ref(),
|
||||
Some(textures),
|
||||
self.settings.horizon.api,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
{
|
||||
Ok(renderer) => renderer,
|
||||
@@ -190,18 +210,18 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
// Everything loaded: switch over.
|
||||
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
|
||||
self.armor_stand_assets.reload_trims(root);
|
||||
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root);
|
||||
self.armor_stand_assets.reload_trims(&root);
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.to_path_buf();
|
||||
self.options.resources = root.clone();
|
||||
let context = pack.context.clone();
|
||||
self.pack = pack;
|
||||
self.gui = gui;
|
||||
// The atlas's sprites moved: the particle engine re-reads every rect
|
||||
// and the packs' sprite sets, or every particle draws a stale region.
|
||||
self.particles
|
||||
.reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
|
||||
.reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
|
||||
*self
|
||||
@@ -210,11 +230,11 @@ impl Game<'_> {
|
||||
.villager_hats
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
horizon_client::mobs::villager_hats::VillagerHats::load(root);
|
||||
horizon_client::ui::load_metadata(root);
|
||||
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
|
||||
horizon_client::mobs::villager_hats::VillagerHats::load(&root);
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
lang::set(lang::Language::load(&[&root], &lang::code()));
|
||||
self.refresh_remote_language();
|
||||
self.audio.reload(root);
|
||||
self.audio.reload(&root);
|
||||
self.clear_lod();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.pending_uploads.clear();
|
||||
@@ -229,4 +249,66 @@ impl Game<'_> {
|
||||
self.creative_screen.forget_items();
|
||||
Ok(())
|
||||
}
|
||||
/// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then
|
||||
/// applied (`PackReload` runs the same two phases across frames).
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
let staged = self.stage_selected_packs()?;
|
||||
self.apply_staged_pack(staged)
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a
|
||||
/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay
|
||||
/// root, the models and sprites, the menu bundle, the pack's tables and its
|
||||
/// CPU mip chains.
|
||||
pub(super) fn stage_pack(
|
||||
vanilla_root: &Path,
|
||||
selected: &[String],
|
||||
server: &[Pack],
|
||||
mods_enabled: &BTreeSet<String>,
|
||||
dev_mods: &[PathBuf],
|
||||
assets: &Arc<Assets>,
|
||||
res: &Resources,
|
||||
context: &WorkerContext,
|
||||
force_unicode: bool,
|
||||
japanese: bool,
|
||||
progress: &super::Progress,
|
||||
) -> horizon_client::Result<StagedPack> {
|
||||
let root = startup_root(vanilla_root, selected, server)?;
|
||||
let root = super::mods::content_root(&root, mods_enabled, dev_mods);
|
||||
progress.set(1);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let mut atlas = HudAtlas::build_over(&root, vanilla_root)?;
|
||||
atlas.font.uniform = force_unicode;
|
||||
atlas.font.japanese = japanese;
|
||||
let gui = UiResources {
|
||||
atlas,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)),
|
||||
};
|
||||
progress.set(2);
|
||||
let pack = PackResources::load(
|
||||
&root,
|
||||
assets.clone(),
|
||||
render,
|
||||
res.facts(),
|
||||
context.biomes.clone(),
|
||||
context.light.clone(),
|
||||
)?;
|
||||
let textures = prepare_renderer_textures(&pack);
|
||||
progress.set(3);
|
||||
Ok(StagedPack {
|
||||
root,
|
||||
gui,
|
||||
pack,
|
||||
textures,
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes
|
||||
/// it on the render thread).
|
||||
pub(super) struct StagedPack {
|
||||
pub root: PathBuf,
|
||||
pub gui: UiResources,
|
||||
pub pack: PackResources,
|
||||
pub textures: PreparedTextures,
|
||||
}
|
||||
@@ -193,12 +193,12 @@ impl Game<'_> {
|
||||
if let Some(a) = p.arrow.as_mut() {
|
||||
a.last_state = in_block.and_then(|t| self.state_from_nbt(&t));
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
"minecraft:fireball" | "minecraft:small_fireball" => {
|
||||
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
|
||||
self.creatures.projectiles.push(p);
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
"minecraft:falling_block" => {
|
||||
@@ -290,14 +290,22 @@ impl Game<'_> {
|
||||
"minecraft:ender_dragon" => {
|
||||
let seed = self.world.random.next_long();
|
||||
if self.dragon.is_none()
|
||||
&& let Some(d) = super::dragon::Dragon::from_nbt(&tag, seed)
|
||||
&& let Some(mut d) = super::dragon::Dragon::from_nbt(&tag, seed)
|
||||
{
|
||||
// EnderDragon's constructor takes its id and its
|
||||
// parts' from the entity counter.
|
||||
d.id = self.next_entity + 1;
|
||||
self.next_entity += 9;
|
||||
self.dragon = Some(d);
|
||||
}
|
||||
}
|
||||
"minecraft:end_crystal" => {
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
if let Some(c) = super::dragon::Crystal::from_nbt(&tag, time) {
|
||||
if let Some(mut c) = super::dragon::Crystal::from_nbt(&tag, time) {
|
||||
// EndCrystal's constructor takes an id from the
|
||||
// entity counter.
|
||||
self.next_entity += 1;
|
||||
c.id = self.next_entity;
|
||||
self.crystals.push(c);
|
||||
}
|
||||
}
|
||||
@@ -533,6 +541,14 @@ impl Game<'_> {
|
||||
self.persist.uuids.remove(&id);
|
||||
self.persist.raw.remove(&id);
|
||||
}
|
||||
/// The compound an entity without a Horizon entity ID was loaded with
|
||||
/// (the crystals and the dragon), found again by type and position.
|
||||
pub(super) fn persist_anon(&self, kind: &str, p: Vec3) -> (Option<[i32; 4]>, Option<&Tag>) {
|
||||
self.persist
|
||||
.anon
|
||||
.get(&anon_key(kind, p))
|
||||
.map_or((None, None), |(_, uuid, raw)| (*uuid, Some(raw)))
|
||||
}
|
||||
/// The compound an entity was loaded with (and commands have edited).
|
||||
pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> {
|
||||
self.persist.raw.get(&id)
|
||||
|
||||
@@ -218,11 +218,20 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
// A living mount at the default scale and camera distance.
|
||||
// Camera.setup: the living mount's scale and camera distance
|
||||
// attributes (the happy ghast's is 8, the default 4).
|
||||
let mount = self
|
||||
.riding
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|_| (1.0, 4.0));
|
||||
.map(|m| {
|
||||
let attribute = |name: &str, default: f64| {
|
||||
m.kind.attributes.get(name).copied().unwrap_or(default)
|
||||
};
|
||||
(
|
||||
attribute("minecraft:scale", 1.0) as f32,
|
||||
attribute("minecraft:camera_distance", 4.0),
|
||||
)
|
||||
});
|
||||
// AbstractBoat.positionRider turns a passenger by the boat's
|
||||
// deltaRotation after its yRotO was saved, and LocalPlayer.getViewYRot
|
||||
// interpolates a passenger's yaw: the boat's turn is spread over the
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
//! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`),
|
||||
//! filling glass bottles (`BottleItem`), the per-tick effect application,
|
||||
//! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`).
|
||||
use super::{Game, Screen, item_draw::owner_at};
|
||||
use super::{
|
||||
Game, Screen,
|
||||
dragon::{Breath, FightPlayer, FightWho},
|
||||
item_draw::owner_at,
|
||||
remote_projectiles::cloud_client_tick,
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light,
|
||||
@@ -12,9 +17,13 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays, inventory_effects,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
/// A thrown splash or lingering potion in flight.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -31,6 +40,35 @@ pub(super) struct ThrownPotion {
|
||||
/// for a dispenser's).
|
||||
owner: Option<Text>,
|
||||
}
|
||||
/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`.
|
||||
pub(super) const LINGERING_WAIT: i32 = 10;
|
||||
/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`.
|
||||
const REAPPLICATION_DELAY: i32 = 20;
|
||||
/// An entity a cloud has affected (`AreaEffectCloud.victims`).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Victim {
|
||||
Mob(u32),
|
||||
Player(FightWho),
|
||||
}
|
||||
/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects
|
||||
/// (at the lingering item's 0.25 duration scale) and who it has affected.
|
||||
pub(super) struct PotionCloud {
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
/// The potion's colour (`PotionContents.getColor`).
|
||||
color: u32,
|
||||
/// Each victim and the tick it can be affected again.
|
||||
victims: Vec<(Victim, i32)>,
|
||||
/// Who its instant damage names (`indirectMagic(cloud, owner)`): the
|
||||
/// thrower, else the cloud.
|
||||
source: Text,
|
||||
}
|
||||
impl PotionCloud {
|
||||
/// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour.
|
||||
pub(super) fn particle(&self) -> ParticleOptions {
|
||||
let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0;
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0])
|
||||
}
|
||||
}
|
||||
/// A mob's effect ids carry the `minecraft:` namespace.
|
||||
fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
@@ -48,6 +86,39 @@ fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
.get(id)
|
||||
.copied()
|
||||
}
|
||||
/// `LivingEntity.canBeAffected` with the mob overrides (`WitherBoss` and
|
||||
/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison,
|
||||
/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags
|
||||
/// decide first, then the tag of mobs that ignore poison and regeneration.
|
||||
fn mob_can_be_affected(
|
||||
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
|
||||
kind: &str,
|
||||
effect: &str,
|
||||
) -> bool {
|
||||
let effect = effect.trim_start_matches("minecraft:");
|
||||
let kind = kind.trim_start_matches("minecraft:");
|
||||
let overridden = match kind {
|
||||
"wither" | "wither_skeleton" => effect == "wither",
|
||||
"spider" | "cave_spider" | "nautilus" | "zombie_nautilus" => effect == "poison",
|
||||
"parched" => effect == "weakness",
|
||||
_ => false,
|
||||
};
|
||||
if overridden {
|
||||
return false;
|
||||
}
|
||||
let in_tag = |tag: &str| {
|
||||
tags.get(tag)
|
||||
.is_some_and(|t| t.contains(&format!("minecraft:{kind}")))
|
||||
};
|
||||
if in_tag("minecraft:immune_to_infested") {
|
||||
effect != "infested"
|
||||
} else if in_tag("minecraft:immune_to_oozing") {
|
||||
effect != "oozing"
|
||||
} else {
|
||||
!in_tag("minecraft:ignores_poison_and_regen")
|
||||
|| (effect != "regeneration" && effect != "poison")
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// A splash potion, lingering potion or experience bottle in flight (the
|
||||
/// player's throws and dispensers both end up here).
|
||||
@@ -391,6 +462,7 @@ impl Game<'_> {
|
||||
self.tick_mob_effects();
|
||||
if running {
|
||||
self.tick_thrown_potions();
|
||||
self.tick_potion_clouds();
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
|
||||
@@ -406,8 +478,10 @@ impl Game<'_> {
|
||||
}
|
||||
// The omens act from inside the effect tick (BadOmenMobEffect,
|
||||
// RaidOmenMobEffect).
|
||||
let absorption_empty = player.health.absorption() <= 0.0;
|
||||
let mut effects = std::mem::take(&mut self.potions.effects);
|
||||
let (actions, raid_omen_added) = effects.tick_with(&mut |e| self.omen_tick(e));
|
||||
let (actions, raid_omen_added) =
|
||||
effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty);
|
||||
self.potions.effects = effects;
|
||||
if raid_omen_added {
|
||||
self.raid_omen_added();
|
||||
@@ -548,29 +622,176 @@ impl Game<'_> {
|
||||
let state =
|
||||
self.world
|
||||
.block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
if self.res.facts().get(state).full_collision
|
||||
|| self.mobs.iter().any(|m| {
|
||||
m.alive()
|
||||
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
|
||||
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
|
||||
&& p.y >= m.position.y
|
||||
&& p.y <= m.position.y + m.kind.height
|
||||
})
|
||||
{
|
||||
hit = Some(p);
|
||||
let mob = self.mobs.iter().find(|m| {
|
||||
m.alive()
|
||||
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
|
||||
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
|
||||
&& p.y >= m.position.y
|
||||
&& p.y <= m.position.y + m.kind.height
|
||||
});
|
||||
if self.res.facts().get(state).full_collision || mob.is_some() {
|
||||
hit = Some((p, mob.map(|m| m.position)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(p) = hit {
|
||||
landed.push((t.stack.clone(), p, t.owner.clone()));
|
||||
if let Some((p, mob)) = hit {
|
||||
landed.push((t.stack.clone(), p, mob, t.owner.clone()));
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
for (stack, at, owner) in landed {
|
||||
self.splash(&stack, at, owner);
|
||||
for (stack, at, mob, owner) in landed {
|
||||
self.splash(&stack, at, mob, owner);
|
||||
}
|
||||
}
|
||||
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
|
||||
/// tick's particles, then the server tick.
|
||||
fn tick_potion_clouds(&mut self) {
|
||||
if self.breath_clouds.iter().all(|c| c.potion.is_none()) {
|
||||
return;
|
||||
}
|
||||
let players: Vec<FightPlayer> = self
|
||||
.fight_players()
|
||||
.into_iter()
|
||||
.filter(|p| p.alive)
|
||||
.collect();
|
||||
let mut clouds = std::mem::take(&mut self.breath_clouds);
|
||||
clouds.retain_mut(|c| c.potion.is_none() || self.tick_potion_cloud(c, &players));
|
||||
self.breath_clouds.append(&mut clouds);
|
||||
}
|
||||
/// `AreaEffectCloud.serverTick` for a lingering potion's cloud: after its
|
||||
/// wait it shrinks, and every 5 ticks it affects the living entities in
|
||||
/// its box and radius that were not affected in the last 20 ticks, each
|
||||
/// taking some of its radius. False once it is gone.
|
||||
fn tick_potion_cloud(&mut self, c: &mut Breath, players: &[FightPlayer]) -> bool {
|
||||
c.age += 1;
|
||||
let Some(potion) = c.potion.as_mut() else {
|
||||
return true;
|
||||
};
|
||||
if c.age - LINGERING_WAIT >= c.duration {
|
||||
return false;
|
||||
}
|
||||
let waiting = c.age < LINGERING_WAIT;
|
||||
cloud_client_tick(
|
||||
&mut self.particles,
|
||||
c.at,
|
||||
c.radius,
|
||||
waiting,
|
||||
&potion.particle(),
|
||||
);
|
||||
if waiting {
|
||||
return true;
|
||||
}
|
||||
// setRadiusPerTick(-radius / duration).
|
||||
c.radius += -3.0 / 600.0;
|
||||
if c.radius < 0.5 {
|
||||
return false;
|
||||
}
|
||||
if c.age % 5 != 0 {
|
||||
return true;
|
||||
}
|
||||
potion.victims.retain(|(_, until)| c.age < *until);
|
||||
let r = f64::from(c.radius);
|
||||
let area = Aabb::new(
|
||||
Vec3::new(c.at[0] - r, c.at[1], c.at[2] - r),
|
||||
Vec3::new(c.at[0] + r, c.at[1] + 0.5, c.at[2] + r),
|
||||
);
|
||||
let mut inside = Vec::new();
|
||||
for p in players.iter().filter(|p| p.bounds.intersects(area)) {
|
||||
inside.push((Victim::Player(p.who), p.position.x, p.position.z));
|
||||
}
|
||||
let effects = potion.effects;
|
||||
let tags = &self.res.stations().enchantments.entity_tags;
|
||||
// Only mobs that at least one of the cloud's effects can affect are
|
||||
// taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so
|
||||
// the others neither become victims nor use up its radius.
|
||||
for m in self.mobs.iter().filter(|m| {
|
||||
m.alive()
|
||||
&& m.bounds().intersects(area)
|
||||
&& effects
|
||||
.iter()
|
||||
.any(|(id, ..)| mob_can_be_affected(tags, &m.kind.id, id))
|
||||
}) {
|
||||
inside.push((Victim::Mob(m.id), m.position.x, m.position.z));
|
||||
}
|
||||
for (victim, x, z) in inside {
|
||||
let (dx, dz) = (x - c.at[0], z - c.at[2]);
|
||||
if potion.victims.iter().any(|(v, _)| *v == victim)
|
||||
|| dx * dx + dz * dz > f64::from(c.radius * c.radius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
potion.victims.push((victim, c.age + REAPPLICATION_DELAY));
|
||||
self.cloud_affect(victim, effects, c.at, &potion.source);
|
||||
// setRadiusOnUse(-0.5).
|
||||
c.radius -= 0.5;
|
||||
if c.radius < 0.5 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// What a cloud puts on one entity: instant effects at half strength
|
||||
/// (`applyInstantaneousEffect` with 0.5), the others for their duration
|
||||
/// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`).
|
||||
fn cloud_affect(
|
||||
&mut self,
|
||||
victim: Victim,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
at: [f64; 3],
|
||||
source: &Text,
|
||||
) {
|
||||
let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1);
|
||||
match victim {
|
||||
Victim::Player(who) => self.as_fight_player(who, |g| {
|
||||
for (id, duration, amp) in effects {
|
||||
let Some(e) = brewing::effect(id) else {
|
||||
continue;
|
||||
};
|
||||
if e.instant {
|
||||
if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) {
|
||||
g.apply_indirect_effect_action(a, source);
|
||||
}
|
||||
} else {
|
||||
g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp));
|
||||
}
|
||||
}
|
||||
}),
|
||||
Victim::Mob(mob) => {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
|
||||
return;
|
||||
};
|
||||
// Undead invert healing and harming.
|
||||
let tags = &self.res.stations().enchantments.entity_tags;
|
||||
let inverted = tags
|
||||
.get("minecraft:inverted_healing_and_harm")
|
||||
.is_some_and(|t| t.contains(&self.mobs[i].kind.id));
|
||||
for (id, duration, amp) in effects {
|
||||
let m = &mut self.mobs[i];
|
||||
match *id {
|
||||
"instant_damage" | "instant_health" => {
|
||||
let amount =
|
||||
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
|
||||
if (*id == "instant_damage") != inverted {
|
||||
m.hurt(amount(6), Some(at), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + amount(4)).min(m.kind.max_health);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// `LivingEntity.addEffect` refuses what the mob
|
||||
// cannot be affected by.
|
||||
if mob_can_be_affected(tags, &m.kind.id, id)
|
||||
&& let Some(name) = mob_effect_id(id)
|
||||
{
|
||||
m.add_effect(name, scaled(*duration), *amp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
|
||||
@@ -672,12 +893,23 @@ impl Game<'_> {
|
||||
self.submit_item_world(&state, pose, vertex_light(light), out);
|
||||
}
|
||||
}
|
||||
/// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a
|
||||
/// potion or a cloud): indirectMagic, not plain magic.
|
||||
fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) {
|
||||
let a = match a {
|
||||
EffectAction::Hurt("minecraft:magic", amount) => {
|
||||
self.note_direct_entity("minecraft:indirect_magic", source.clone());
|
||||
EffectAction::Hurt("minecraft:indirect_magic", amount)
|
||||
}
|
||||
a => a,
|
||||
};
|
||||
self.apply_effect_action(a);
|
||||
}
|
||||
/// A splash potion's effects on the player in place, by their distance.
|
||||
fn splash_on_player(
|
||||
&mut self,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
at: Vec3,
|
||||
scale: f64,
|
||||
source: &Text,
|
||||
) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
@@ -694,22 +926,10 @@ impl Game<'_> {
|
||||
let instant = brewing::effect(id).is_some_and(|e| e.instant);
|
||||
if instant {
|
||||
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
|
||||
// HealOrHarmMobEffect.applyInstantaneousEffect with
|
||||
// a source: indirectMagic, not plain magic.
|
||||
let a = match a {
|
||||
EffectAction::Hurt("minecraft:magic", amount) => {
|
||||
self.note_direct_entity(
|
||||
"minecraft:indirect_magic",
|
||||
source.clone(),
|
||||
);
|
||||
EffectAction::Hurt("minecraft:indirect_magic", amount)
|
||||
}
|
||||
a => a,
|
||||
};
|
||||
self.apply_effect_action(a);
|
||||
self.apply_indirect_effect_action(a, source);
|
||||
}
|
||||
} else {
|
||||
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
|
||||
let d = (intensity * f64::from(*duration) + 0.5) as i32;
|
||||
if d > 20
|
||||
&& let Some(e) = brewing::effect(id)
|
||||
{
|
||||
@@ -720,8 +940,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
|
||||
fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option<Text>) {
|
||||
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
|
||||
/// for a lingering potion; `mob` is where it hit a mob.
|
||||
fn splash(&mut self, stack: &ItemStack, at: Vec3, mob: Option<Vec3>, owner: Option<Text>) {
|
||||
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
|
||||
if &*stack.item == "minecraft:experience_bottle" {
|
||||
// ThrownExperienceBottle.onHit: the water-coloured splash (level
|
||||
@@ -745,23 +966,41 @@ impl Game<'_> {
|
||||
let color = brewing::potion_color(stack.potion());
|
||||
self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32);
|
||||
self.level_event_at(if instant { 1054 } else { 1053 }, block, 0);
|
||||
let scale = if lingering { 0.25 } else { 1.0 };
|
||||
let Some(def) = potion else {
|
||||
return;
|
||||
};
|
||||
if lingering {
|
||||
// The cloud sits at the mob it hit (its feet), else the impact;
|
||||
// radius 3, wait 10, 600 ticks, shrinking to nothing, and a
|
||||
// potion with no effects leaves none.
|
||||
if !def.effects.is_empty() {
|
||||
let at = mob.unwrap_or(at);
|
||||
self.breath_clouds.push(Breath {
|
||||
id: 0,
|
||||
at: [at.x, at.y, at.z],
|
||||
radius: 3.0,
|
||||
age: 0,
|
||||
duration: 600,
|
||||
from_dragon: false,
|
||||
potion: Some(PotionCloud {
|
||||
effects: def.effects,
|
||||
color,
|
||||
victims: Vec::new(),
|
||||
source: owner.unwrap_or_else(|| {
|
||||
Text::translate("entity.minecraft.area_effect_cloud", &[])
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Every player in range (`ThrownSplashPotion.applySplash` over
|
||||
// `LivingEntity`s, the players among them).
|
||||
// Instant damage is indirectMagic(potion or cloud, owner), which the
|
||||
// death message names by the owner, else by the potion or cloud.
|
||||
let source = owner.unwrap_or_else(|| {
|
||||
let entity = if lingering {
|
||||
"entity.minecraft.area_effect_cloud"
|
||||
} else {
|
||||
"entity.minecraft.splash_potion"
|
||||
};
|
||||
Text::translate(entity, &[])
|
||||
});
|
||||
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale, &source));
|
||||
// Instant damage is indirectMagic(potion, owner), which the death
|
||||
// message names by the owner, else by the potion.
|
||||
let source =
|
||||
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
|
||||
self.for_each_player(|g| g.splash_on_player(def.effects, at, &source));
|
||||
// Mobs take the instant effects (undead invert healing and harming).
|
||||
let undead = &self.res.stations().enchantments.entity_tags;
|
||||
let mut hits = Vec::new();
|
||||
@@ -784,7 +1023,7 @@ impl Game<'_> {
|
||||
_ => {
|
||||
// `Potion.applySplash`: timed effects last in
|
||||
// proportion to how close the mob was.
|
||||
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
|
||||
let d = (intensity * f64::from(*duration) + 0.5) as i32;
|
||||
if d > 20
|
||||
&& let Some(name) = mob_effect_id(id)
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1036,6 +1036,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 +1062,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,
|
||||
|
||||
@@ -1954,6 +1954,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
|
||||
@@ -2052,13 +2068,22 @@ 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();
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
for (slot, item) in items {
|
||||
// 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,
|
||||
@@ -2066,7 +2091,9 @@ 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 => e.head = item,
|
||||
7 => m.set_saddle(stack),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -2185,6 +2212,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 {
|
||||
@@ -3384,13 +3414,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 {
|
||||
@@ -3404,6 +3433,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).
|
||||
@@ -3578,11 +3615,15 @@ impl Game<'_> {
|
||||
let head_lerps = &mut r.head_lerps;
|
||||
let players = &r.level_players;
|
||||
let mut using = Vec::new();
|
||||
let mut wither_heads = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
let player = players.contains_key(&(m.id as i32));
|
||||
if !running && !player {
|
||||
continue;
|
||||
}
|
||||
if m.species() == horizon_client::mobs::Species::Wither {
|
||||
wither_heads.extend((0..3).map(|i| horizon_client::mobs::wither::head_pos(m, i)));
|
||||
}
|
||||
m.previous = m.position;
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_body_yaw = m.body_yaw;
|
||||
@@ -3655,7 +3696,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,
|
||||
@@ -3713,10 +3754,24 @@ impl Game<'_> {
|
||||
d.tick(world, facts, collision, false, &self.res.motion().fluids);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
// WitherBoss.aiStep runs on a client too: smoke around each head.
|
||||
let smoke = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::Smoke,
|
||||
);
|
||||
for h in wither_heads {
|
||||
let r = self.particles.random();
|
||||
let at = [
|
||||
h[0] + r.next_gaussian() * 0.3,
|
||||
h[1] + r.next_gaussian() * 0.3,
|
||||
h[2] + r.next_gaussian() * 0.3,
|
||||
];
|
||||
self.particles.add(&smoke, at, [0.0; 3]);
|
||||
}
|
||||
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
|
||||
@@ -4153,6 +4208,7 @@ 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 let Some(stack) = held.as_ref()
|
||||
&& self.predict_item_on_block(stack, &hit)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,12 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
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.
|
||||
|
||||
@@ -66,8 +66,11 @@ impl Game<'_> {
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Axolotl
|
||||
| Species::Tadpole => item == "minecraft:water_bucket",
|
||||
| Species::Axolotl => item == "minecraft:water_bucket",
|
||||
// Tadpole.mobInteract: frog food, else the bucket.
|
||||
Species::Tadpole => {
|
||||
item == "minecraft:water_bucket" || m.kind.food.iter().any(|f| f.as_str() == item)
|
||||
}
|
||||
Species::Armadillo => item == "minecraft:brush",
|
||||
// ZombieVillager.mobInteract: CONSUME for a golden apple.
|
||||
Species::ZombieVillager => item == "minecraft:golden_apple",
|
||||
|
||||
@@ -23,6 +23,58 @@ fn default_cloud_particle() -> ParticleOptions {
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
|
||||
}
|
||||
|
||||
/// `AreaEffectCloud.clientTick`: while waiting, now and then two particles
|
||||
/// near the middle; then about one per square block of the disc, drifting
|
||||
/// (an entity effect stays still).
|
||||
pub(super) fn cloud_client_tick(
|
||||
particles: &mut horizon_client::particles::ParticleEngine,
|
||||
at: [f64; 3],
|
||||
radius: f32,
|
||||
waiting: bool,
|
||||
particle: &ParticleOptions,
|
||||
) {
|
||||
if waiting && particles.random().next_boolean() {
|
||||
return;
|
||||
}
|
||||
let (count, spread) = if waiting {
|
||||
(2, 0.2)
|
||||
} else {
|
||||
(
|
||||
(std::f32::consts::PI * radius * radius).ceil() as i32,
|
||||
radius,
|
||||
)
|
||||
};
|
||||
for _ in 0..count {
|
||||
let r = particles.random();
|
||||
let angle = r.next_float() * std::f32::consts::TAU;
|
||||
let distance = r.next_float().sqrt() * spread;
|
||||
let at = [
|
||||
at[0] + f64::from(angle.cos() * distance),
|
||||
at[1],
|
||||
at[2] + f64::from(angle.sin() * distance),
|
||||
];
|
||||
let (options, velocity) = if particle.ty == ParticleType::EntityEffect {
|
||||
let default = waiting && r.next_boolean();
|
||||
let options = if default {
|
||||
default_cloud_particle()
|
||||
} else {
|
||||
particle.clone()
|
||||
};
|
||||
(options, [0.0; 3])
|
||||
} else if waiting {
|
||||
(particle.clone(), [0.0; 3])
|
||||
} else {
|
||||
let v = [
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
f64::from(0.01f32),
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
];
|
||||
(particle.clone(), v)
|
||||
};
|
||||
particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
/// The projectile a type id names, as Horizon models it.
|
||||
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
Some(match kind {
|
||||
@@ -77,8 +129,36 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
|
||||
}
|
||||
p.velocity.y -= 0.05;
|
||||
}
|
||||
/// A server's client ticks a projectile itself: it flies on between the
|
||||
/// tracker's updates (10 to 20 ticks apart) with its own gravity and drag,
|
||||
/// and a block stops it there; only the server decides what it hits.
|
||||
pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
|
||||
if !matches!(p.tick(w).1, Hit::None) {
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractHurtingProjectile.getTrailParticle`: smoke for every one of them
|
||||
/// but the wind charges (no trail) and the dragon's fireball (its own).
|
||||
pub(super) fn has_smoke_trail(kind: &ProjectileKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
ProjectileKind::SmallFireball
|
||||
| ProjectileKind::LargeFireball { .. }
|
||||
| ProjectileKind::WitherSkull { .. }
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a
|
||||
/// block above each position.
|
||||
pub(super) fn smoke_trails(&mut self, at: impl IntoIterator<Item = Vec3>) {
|
||||
let smoke = ParticleOptions::simple(ParticleType::Smoke);
|
||||
for p in at {
|
||||
self.particles.add(&smoke, [p.x, p.y + 0.5, p.z], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleAddEntity` for these types; false when `kind` isn't one.
|
||||
pub(super) fn spawn_remote_projectile(
|
||||
&mut self,
|
||||
@@ -171,7 +251,7 @@ impl Game<'_> {
|
||||
p.velocity = velocity;
|
||||
p.yaw = yaw;
|
||||
p.pitch = pitch;
|
||||
self.creatures.projectiles.push(p);
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -347,15 +427,19 @@ impl Game<'_> {
|
||||
p.age += 1;
|
||||
p.position = p.position.add_vector(p.velocity);
|
||||
}
|
||||
_ => {
|
||||
if !matches!(p.tick(&world).1, Hit::None) {
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
_ => remote_projectile_tick(p, &world),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.creatures.projectiles = projectiles;
|
||||
let trails: Vec<Vec3> = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| has_smoke_trail(&p.kind))
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
self.smoke_trails(trails);
|
||||
for t in &mut self.tridents {
|
||||
let id = t.id;
|
||||
t.remote_step(|at| step(id, at));
|
||||
@@ -414,52 +498,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
|
||||
/// particles near the middle; then about one per square block of the
|
||||
/// disc, drifting (an entity effect stays still).
|
||||
/// `AreaEffectCloud.clientTick` for each cloud a server tracks.
|
||||
fn tick_remote_clouds(&mut self) {
|
||||
for c in &self.potions.clouds {
|
||||
let r = self.particles.random();
|
||||
if c.waiting && r.next_boolean() {
|
||||
continue;
|
||||
}
|
||||
let (count, spread) = if c.waiting {
|
||||
(2, 0.2)
|
||||
} else {
|
||||
(
|
||||
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
|
||||
c.radius,
|
||||
)
|
||||
};
|
||||
for _ in 0..count {
|
||||
let r = self.particles.random();
|
||||
let angle = r.next_float() * std::f32::consts::TAU;
|
||||
let distance = r.next_float().sqrt() * spread;
|
||||
let at = [
|
||||
c.at[0] + f64::from(angle.cos() * distance),
|
||||
c.at[1],
|
||||
c.at[2] + f64::from(angle.sin() * distance),
|
||||
];
|
||||
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
|
||||
let default = c.waiting && r.next_boolean();
|
||||
let options = if default {
|
||||
default_cloud_particle()
|
||||
} else {
|
||||
c.particle.clone()
|
||||
};
|
||||
(options, [0.0; 3])
|
||||
} else if c.waiting {
|
||||
(c.particle.clone(), [0.0; 3])
|
||||
} else {
|
||||
let v = [
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
f64::from(0.01f32),
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
];
|
||||
(c.particle.clone(), v)
|
||||
};
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,3 +575,68 @@ impl Game<'_> {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::mobs::Who;
|
||||
use horizon_core::geometry::Aabb;
|
||||
|
||||
/// A world with nothing in it: the client tick of a projectile only
|
||||
/// needs the blocks around it.
|
||||
struct Empty;
|
||||
impl ProjectileWorld for Empty {
|
||||
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
None
|
||||
}
|
||||
fn in_water(&self, _: Vec3) -> bool {
|
||||
false
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// A thrown item, as a server's client tracks it: the spawn position and
|
||||
/// the motion the server gave it.
|
||||
fn thrown(kind: ProjectileKind) -> Projectile {
|
||||
let mut p = Projectile::new(kind, Vec3::new(0.0, 100.0, 0.0), None);
|
||||
p.velocity = Vec3::new(1.5, 0.0, 0.0);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thrown_item_flies_on_between_the_server_updates() {
|
||||
// A thrown egg, an ender pearl and a wind charge (#1762): the server
|
||||
// sends their position every 10 ticks, and the client ticks them
|
||||
// itself in between, as vanilla's no-op interpolation handler does.
|
||||
for kind in [
|
||||
ProjectileKind::Egg,
|
||||
ProjectileKind::EnderPearl,
|
||||
ProjectileKind::WindCharge,
|
||||
] {
|
||||
let mut p = thrown(kind);
|
||||
remote_projectile_tick(&mut p, &Empty);
|
||||
assert!(p.position.x > 1.0, "the projectile did not fly on: {p:?}");
|
||||
// A second tick moves it further: it keeps flying, not a hop.
|
||||
let first = p.position.x;
|
||||
remote_projectile_tick(&mut p, &Empty);
|
||||
assert!(p.position.x > first, "the projectile stopped: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thrown_item_falls_and_drags_like_a_throwable_projectile() {
|
||||
// ThrowableProjectile.tick: gravity 0.03, then the air drag 0.99.
|
||||
let mut p = thrown(ProjectileKind::Egg);
|
||||
remote_projectile_tick(&mut p, &Empty);
|
||||
assert!((p.velocity.x - 1.5 * 0.99).abs() < 1e-9, "{p:?}");
|
||||
assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, "{p:?}");
|
||||
// A wind charge keeps its speed: no gravity and no drag
|
||||
// (AbstractWindCharge: acceleration 0, inertia 1).
|
||||
let mut p = thrown(ProjectileKind::WindCharge);
|
||||
remote_projectile_tick(&mut p, &Empty);
|
||||
assert!((p.velocity.x - 1.5).abs() < 1e-9, "{p:?}");
|
||||
assert!((p.velocity.y - 0.0).abs() < 1e-9, "{p:?}");
|
||||
}
|
||||
}
|
||||
@@ -288,10 +288,11 @@ impl Game<'_> {
|
||||
e[2].floor() as i32,
|
||||
];
|
||||
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
|
||||
if rain > 0.0 {
|
||||
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10).
|
||||
// The Horizon Weather option off means no rain visuals at all.
|
||||
if rain > 0.0 && self.settings.horizon.weather {
|
||||
// ClientLevel.tickWeatherEffects: rain splashes within the weather radius.
|
||||
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
|
||||
let radius = 10;
|
||||
let radius = self.settings.weather_radius;
|
||||
let diameter = 2 * radius + 1;
|
||||
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
|
||||
for _ in 0..count {
|
||||
|
||||
@@ -139,8 +139,10 @@ impl Game<'_> {
|
||||
| Target::Painting(id)
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id)
|
||||
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
|
||||
// Orbs and projectiles are addressed by index and have no UUID here.
|
||||
// Orbs and projectiles have no UUID here.
|
||||
Target::Orb(_) | Target::Projectile(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -188,7 +190,11 @@ impl Game<'_> {
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id) => self.persist.raw.get(&id),
|
||||
Target::Player => self.level.player_extra.as_ref(),
|
||||
Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None,
|
||||
Target::Guest(_)
|
||||
| Target::Orb(_)
|
||||
| Target::Projectile(_)
|
||||
| Target::Crystal(_)
|
||||
| Target::Dragon(_) => None,
|
||||
};
|
||||
match raw.and_then(|r| r.get("Tags")) {
|
||||
Some(Tag::List(l)) => l
|
||||
|
||||
@@ -1194,14 +1194,14 @@ impl Game<'_> {
|
||||
|
||||
/// `LightmapRenderStateExtractor`: the night vision effect's intensity
|
||||
/// (`GameRenderer.nightVisionScale`), or conduit power's water vision.
|
||||
pub(super) fn night_vision_intensity(&self) -> f32 {
|
||||
pub(super) fn night_vision_intensity(&self, partial: f32) -> f32 {
|
||||
use horizon_client::fog::FogType;
|
||||
if let Some(e) = self.potions.effects.get("night_vision") {
|
||||
// MobEffectInstance.endsWithin(200); -1 is infinite.
|
||||
return if e.duration < 0 || e.duration > 200 {
|
||||
1.0
|
||||
} else {
|
||||
0.7 + (e.duration as f32 * std::f32::consts::PI * 0.2).sin() * 0.3
|
||||
0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3
|
||||
};
|
||||
}
|
||||
if self.potions.effects.get("conduit_power").is_some()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
|
||||
//! server list's setting, downloads into `downloads/`, and a resource reload
|
||||
//! with the server's packs on top of the player's own.
|
||||
use super::Game;
|
||||
use super::{Game, Progress};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
@@ -82,6 +82,9 @@ pub(super) struct ServerPackState {
|
||||
pub(super) active: Vec<packs::Pack>,
|
||||
/// The download batches' notifiers, oldest first.
|
||||
notifiers: Vec<PackNotifier>,
|
||||
/// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the
|
||||
/// frame draws its progress until `tick_server_packs` applies it.
|
||||
pub(super) reload: Option<PackReload>,
|
||||
}
|
||||
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
|
||||
/// own `SystemToast`.
|
||||
@@ -191,6 +194,21 @@ fn saves() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_default().join("saves")
|
||||
}
|
||||
|
||||
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
|
||||
const RELOAD_LINES: [&str; 4] = [
|
||||
"Preparing resources",
|
||||
"Loading assets",
|
||||
"Loading pack tables",
|
||||
"Applying resources",
|
||||
];
|
||||
/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a
|
||||
/// worker while `progress` feeds the loading screen, then the apply lands
|
||||
/// on the render thread.
|
||||
pub(super) struct PackReload {
|
||||
pub(super) progress: Progress,
|
||||
handle: std::thread::JoinHandle<std::result::Result<super::packs::StagedPack, String>>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ConnectScreen`: the server's setting decides what its packs do
|
||||
/// (`configureForServerControl`); single player's own server takes them
|
||||
@@ -314,6 +332,7 @@ impl Game<'_> {
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
return;
|
||||
}
|
||||
self.drive_pack_reload();
|
||||
let finished = self
|
||||
.server_packs
|
||||
.queue
|
||||
@@ -328,13 +347,8 @@ impl Game<'_> {
|
||||
if !tick.download.is_empty() {
|
||||
self.start_pack_downloads(tick.download);
|
||||
}
|
||||
if let Some(mut load) = tick.reload {
|
||||
while let Some(recovery) = {
|
||||
let ok = self.reload_server_packs(&load);
|
||||
self.server_packs.packs.reloaded(ok)
|
||||
} {
|
||||
load = recovery;
|
||||
}
|
||||
if let Some(load) = tick.reload {
|
||||
self.start_pack_reload(&load);
|
||||
}
|
||||
}
|
||||
for (id, status) in self.server_packs.packs.take_replies() {
|
||||
@@ -379,24 +393,90 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.startReload`: the packs on top of the player's
|
||||
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
|
||||
/// readable `pack.mcmeta` or the reload failed.
|
||||
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
|
||||
let packs: Option<Vec<_>> = load
|
||||
/// own, staged on a worker (`Minecraft.reloadResourcePacks` under a
|
||||
/// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no
|
||||
/// readable `pack.mcmeta` fails the reload at once, as `onFailure`.
|
||||
fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) {
|
||||
let server: Option<Vec<_>> = load
|
||||
.iter()
|
||||
.map(|(id, path)| packs::server_pack(*id, path))
|
||||
.collect();
|
||||
let Some(packs) = packs else {
|
||||
let Some(server) = server else {
|
||||
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
|
||||
return false;
|
||||
self.finish_pack_reload(false);
|
||||
return;
|
||||
};
|
||||
self.server_packs.active = packs;
|
||||
match self.reload_selected_packs() {
|
||||
self.server_packs.active = server.clone();
|
||||
// The overlay swallows input until the apply (`LoadingOverlay`):
|
||||
// release what is held so nothing keeps acting while it covers the
|
||||
// frame (`KeyMapping.releaseAll` when a screen opens).
|
||||
self.keys.clear();
|
||||
self.physical_keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let (selected, mods, dev_mods) = (
|
||||
self.settings.resource_packs.clone(),
|
||||
self.settings.mods.enabled.clone(),
|
||||
self.options.dev_mods.clone(),
|
||||
);
|
||||
let (assets, res, context) = (
|
||||
self.pack.assets.clone(),
|
||||
self.res.clone(),
|
||||
self.pack.context.clone(),
|
||||
);
|
||||
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
|
||||
let progress = Progress::with(&RELOAD_LINES);
|
||||
let worker = progress.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("server-pack-reload".into())
|
||||
.spawn(move || {
|
||||
super::packs::stage_pack(
|
||||
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context,
|
||||
unicode, japanese, &worker,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
match handle {
|
||||
Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: could not start the pack reload worker: {e}");
|
||||
self.finish_pack_reload(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PackReloadConfig.Callbacks`: applies the staged reload once its
|
||||
/// worker is done; on failure the packs active before reload again.
|
||||
fn drive_pack_reload(&mut self) {
|
||||
let Some(reload) = &self.server_packs.reload else {
|
||||
return;
|
||||
};
|
||||
if !reload.handle.is_finished() {
|
||||
return;
|
||||
}
|
||||
let reload = self.server_packs.reload.take().unwrap();
|
||||
let staged = reload
|
||||
.handle
|
||||
.join()
|
||||
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
|
||||
let ok = match staged
|
||||
.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: server resource pack reload failed: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
self.finish_pack_reload(ok);
|
||||
}
|
||||
/// `ServerPacks::reloaded` and the recovery reload it can ask for.
|
||||
fn finish_pack_reload(&mut self, ok: bool) {
|
||||
if let Some(recovery) = self.server_packs.packs.reloaded(ok) {
|
||||
self.start_pack_reload(&recovery);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
signs::wood_type,
|
||||
items::ItemStack,
|
||||
signs::{DYE_TEXT, wood_type},
|
||||
ui::EditBox,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
@@ -31,6 +32,157 @@ fn sign_box() -> EditBox {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in
|
||||
/// front of the sign's text (within 90 degrees of its facing).
|
||||
fn facing_sign_front(&self, pos: (i32, i32, i32)) -> bool {
|
||||
let Some(player) = self.player.as_ref() else {
|
||||
return true;
|
||||
};
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let Some(state) = self.pack.assets.registry.state(state) else {
|
||||
return true;
|
||||
};
|
||||
let prop = |key: &str| state.properties.get(key).map(String::as_str);
|
||||
// getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or
|
||||
// ceiling sign's rotation segment.
|
||||
let y_rot = if state.name.contains("_wall_") {
|
||||
match prop("facing") {
|
||||
Some("west") => 90.0,
|
||||
Some("north") => 180.0,
|
||||
Some("east") => 270.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
} else {
|
||||
prop("rotation")
|
||||
.and_then(|r| r.parse::<f32>().ok())
|
||||
.unwrap_or(0.0)
|
||||
* 22.5
|
||||
};
|
||||
// getSignHitboxCenterPosition: only a wall sign's board is off-centre,
|
||||
// against the wall it hangs on.
|
||||
let centre = if state.name.ends_with("_wall_sign") {
|
||||
match y_rot as i32 {
|
||||
90 => (0.9375, 0.5),
|
||||
180 => (0.5, 0.9375),
|
||||
270 => (0.0625, 0.5),
|
||||
_ => (0.5, 0.0625),
|
||||
}
|
||||
} else {
|
||||
(0.5, 0.5)
|
||||
};
|
||||
let xd = player.position.x - (f64::from(pos.0) + centre.0);
|
||||
let zd = player.position.z - (f64::from(pos.2) + centre.1);
|
||||
let player_y_rot = (zd.atan2(xd).to_degrees() - 90.0) as f32;
|
||||
// Mth.degreesDifferenceAbs.
|
||||
let diff = (player_y_rot - y_rot).rem_euclid(360.0);
|
||||
let diff = if diff >= 180.0 { 360.0 - diff } else { diff };
|
||||
diff <= 90.0
|
||||
}
|
||||
/// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed
|
||||
/// sign plays the failed-interaction sound; otherwise a player who may
|
||||
/// build edits the side they face.
|
||||
pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
if sign.waxed {
|
||||
// `getSignInteractionFailedSoundEvent`: a hanging sign has its own.
|
||||
let sound = if sign.hanging {
|
||||
"minecraft:block.hanging_sign.waxed_interact_fail"
|
||||
} else {
|
||||
"minecraft:block.sign.waxed_interact_fail"
|
||||
};
|
||||
self.audio
|
||||
.play(sound, Some(pos.map(|v| f64::from(v) + 0.5)), 1.0, 1.0);
|
||||
} else if self.may_build() {
|
||||
let front = self.facing_sign_front(key);
|
||||
self.open_sign_editor(key, front);
|
||||
}
|
||||
}
|
||||
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
|
||||
/// honeycomb): the item changes the side the player faces and is used up.
|
||||
/// Whether it did; a sign it does not apply to goes on to the empty hand.
|
||||
pub(super) fn use_sign_applicator(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let Some(stack) = held else {
|
||||
return false;
|
||||
};
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
|
||||
return false;
|
||||
};
|
||||
if sign.waxed || !self.may_build() {
|
||||
return false;
|
||||
}
|
||||
let item = &*stack.item;
|
||||
let dye = if item.ends_with("_dye") {
|
||||
self.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.dye.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let colour = dye.and_then(|d| DYE_TEXT.iter().position(|(n, _)| *n == d));
|
||||
if !matches!(
|
||||
item,
|
||||
"minecraft:honeycomb" | "minecraft:glow_ink_sac" | "minecraft:ink_sac"
|
||||
) && colour.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let front = self.facing_sign_front(key);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
|
||||
return false;
|
||||
};
|
||||
let centre = pos.map(|v| f64::from(v) + 0.5);
|
||||
let sound = if item == "minecraft:honeycomb" {
|
||||
// HoneycombItem.tryApplyToSign: waxes any sign, empty or not.
|
||||
sign.waxed = true;
|
||||
"minecraft:item.honeycomb.wax_on"
|
||||
} else {
|
||||
let side = if front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
// SignApplicator.canApplyToSign: the side must have text.
|
||||
if side.is_empty() {
|
||||
return false;
|
||||
}
|
||||
match colour {
|
||||
Some(c) if side.color != c as u8 => {
|
||||
side.color = c as u8;
|
||||
"minecraft:item.dye.use"
|
||||
}
|
||||
None if item == "minecraft:glow_ink_sac" && !side.glowing => {
|
||||
side.glowing = true;
|
||||
"minecraft:item.glow_ink_sac.use"
|
||||
}
|
||||
None if item == "minecraft:ink_sac" && side.glowing => {
|
||||
side.glowing = false;
|
||||
"minecraft:item.ink_sac.use"
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
};
|
||||
if item == "minecraft:honeycomb" {
|
||||
self.level_event_at(3003, pos, 0);
|
||||
}
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.mark_block_entity_updated(key);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
self.sculk_actor(),
|
||||
Some(state),
|
||||
);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// `SignBlock.openTextEdit` for the front side after placement.
|
||||
pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) {
|
||||
self.sign_edit = Some(SignEditor {
|
||||
|
||||
@@ -253,7 +253,7 @@ impl Game<'_> {
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
/// `LivingEntity.makePoofParticles` (entity event 20) for a mob.
|
||||
fn mob_poof(&mut self, id: u32) {
|
||||
pub(super) fn mob_poof(&mut self, id: u32) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -546,6 +546,11 @@ impl Loading {
|
||||
(batch.width, batch.height),
|
||||
self.menu.modifiers.shift_key(),
|
||||
);
|
||||
title.press_rules(
|
||||
&gui.atlas,
|
||||
self.menu.cursor,
|
||||
(batch.width, batch.height),
|
||||
);
|
||||
self.menu_action(event_loop, action);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,9 +256,8 @@ impl Game<'_> {
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
let on_ground = p.ground.on_ground;
|
||||
let (sprinting, flying, gliding, swimming) = (
|
||||
let (sprinting, gliding, swimming) = (
|
||||
p.sprinting,
|
||||
p.flying,
|
||||
p.fall_flying,
|
||||
p.pose == horizon_client::pose::Pose::Swimming,
|
||||
);
|
||||
@@ -272,15 +271,27 @@ impl Game<'_> {
|
||||
});
|
||||
let s = &mut self.stats;
|
||||
match riding {
|
||||
Some(Ok(horizon_client::mobs::Species::Pig)) => s.custom("pig_one_cm", total),
|
||||
Some(Ok(horizon_client::mobs::Species::Strider)) => s.custom("strider_one_cm", total),
|
||||
Some(Ok(sp)) if horizon_client::mobs::equine::is_equine(sp) => {
|
||||
s.custom("horse_one_cm", horizontal)
|
||||
Some(Ok(sp)) => {
|
||||
use horizon_client::mobs::Species;
|
||||
// `checkRidingStatistics`: only these mounts count, the horse
|
||||
// family by the full 3D distance; any other mount nothing.
|
||||
match sp {
|
||||
Species::Pig => s.custom("pig_one_cm", total),
|
||||
Species::Strider => s.custom("strider_one_cm", total),
|
||||
Species::HappyGhast => s.custom("happy_ghast_one_cm", total),
|
||||
Species::Nautilus | Species::ZombieNautilus => {
|
||||
s.custom("nautilus_one_cm", total)
|
||||
}
|
||||
sp if horizon_client::mobs::equine::is_equine(sp) => {
|
||||
s.custom("horse_one_cm", total)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
Some(Err(())) => {
|
||||
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
|
||||
s.custom("boat_one_cm", total)
|
||||
} else {
|
||||
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
|
||||
s.custom("minecart_one_cm", total)
|
||||
}
|
||||
}
|
||||
@@ -300,8 +311,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
None if gliding => s.custom("aviate_one_cm", total),
|
||||
None if flying => s.custom("fly_one_cm", horizontal),
|
||||
None if d.y < 0.0 => s.custom("fall_one_cm", (-d.y * 100.0).round() as i32),
|
||||
// Airborne and not gliding: more than 25 cm sideways per move
|
||||
// (creative flight, or a long jump), not only flying.
|
||||
None if horizontal > 25 => s.custom("fly_one_cm", horizontal),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ impl Game<'_> {
|
||||
self.handle_mob_events(events)?;
|
||||
return Ok(true);
|
||||
}
|
||||
self.stats.custom("talk_to_villager", 1);
|
||||
self.stats.custom("talked_to_villager", 1);
|
||||
self.ensure_offers(index);
|
||||
let m = &self.mobs[index];
|
||||
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
|
||||
@@ -398,18 +398,21 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.and_then(|s| s.merchant.as_ref())
|
||||
.and_then(|m| m.mob);
|
||||
let session = self.merchant_apply(session, mob);
|
||||
let (session, traded) = self.merchant_apply(session, mob);
|
||||
self.merchant_traded(mob, &traded);
|
||||
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
|
||||
m.session = session;
|
||||
}
|
||||
}
|
||||
/// What a menu click's session did, applied to the world and its mob.
|
||||
/// What a menu click's session did, applied to the world and its mob;
|
||||
/// with the result of each trade made, for `merchant_traded`.
|
||||
pub(super) fn merchant_apply(
|
||||
&mut self,
|
||||
session: Box<merchant::Session>,
|
||||
mob: Option<u32>,
|
||||
) -> Box<merchant::Session> {
|
||||
) -> (Box<merchant::Session>, Vec<ItemStack>) {
|
||||
let mut session = session;
|
||||
let mut traded = Vec::new();
|
||||
let events = std::mem::take(&mut session.events);
|
||||
let (villager, origin) = (session.villager, session.origin);
|
||||
if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) {
|
||||
@@ -438,11 +441,26 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(sound, Some(origin), 1.0, pitch);
|
||||
}
|
||||
merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1),
|
||||
merchant::Event::Traded(index) => {
|
||||
traded.extend(session.offers.get(index).map(|o| o.result.clone()));
|
||||
}
|
||||
merchant::Event::TradeSound => {}
|
||||
}
|
||||
}
|
||||
session
|
||||
(session, traded)
|
||||
}
|
||||
/// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade`
|
||||
/// for each trade made: the statistic and the `villager_trade` trigger.
|
||||
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, results: &[ItemStack]) {
|
||||
for result in results {
|
||||
self.stats.custom("traded_with_villager", 1);
|
||||
let villager = mob
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| self.mob_entity(m));
|
||||
if let Some(villager) = villager {
|
||||
self.trigger_trade(&villager, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
|
||||
/// trade, a tick later; the prices follow the new reputation.
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(super) struct ThrownTrident {
|
||||
pub id: u32,
|
||||
pub(super) stack: ItemStack,
|
||||
pub(super) position: Vec3,
|
||||
previous: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
pub(super) velocity: Vec3,
|
||||
pub(super) yaw: f32,
|
||||
pub(super) pitch: f32,
|
||||
@@ -112,7 +112,7 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
fn in_water_or_rain(&self) -> bool {
|
||||
fn in_water_or_rain(&mut self) -> bool {
|
||||
let Some(p) = &self.player else { return false };
|
||||
let (x, y, z) = (
|
||||
p.position.x.floor() as i32,
|
||||
@@ -121,8 +121,8 @@ impl Game<'_> {
|
||||
);
|
||||
let water = self.res.facts().get(self.world.block(x, y, z)).fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
let sky = self.world.light(x, y + 1, z) >> 4 == 15;
|
||||
water || (self.world.weather.raining && sky)
|
||||
let (at, top) = (p.position, p.bounds().max_position().y);
|
||||
water || self.in_rain(at, top)
|
||||
}
|
||||
/// The living entities a spinning player can touch this tick, for
|
||||
/// `LivingEntity.checkAutoSpinAttack` (none when not spinning).
|
||||
|
||||
@@ -68,7 +68,7 @@ impl Game<'_> {
|
||||
velocity,
|
||||
on_ground,
|
||||
);
|
||||
self.creatures.projectiles.push(proj);
|
||||
self.add_projectile(proj);
|
||||
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
|
||||
self.audio
|
||||
.play("minecraft:entity.wind_charge.throw", Some(eye), 0.5, pitch);
|
||||
|
||||
@@ -94,6 +94,8 @@ impl WorldRequest {
|
||||
level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into();
|
||||
level.allow_commands = Some(o.commands(creative));
|
||||
horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?;
|
||||
// WorldCreationUiState.getGameRules, written with the level.
|
||||
level.game_rules = o.rules.by_name();
|
||||
}
|
||||
// Worlds made without the Create New World screen (--world, the
|
||||
// control socket) take its default: commands on for Creative only.
|
||||
|
||||
+38
-30
@@ -1,8 +1,9 @@
|
||||
//! InWorldGameRulesScreen (AbstractGameRulesScreen), opened from World
|
||||
//! Options: the rules by category under a search box, a switch or a number
|
||||
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
|
||||
//! Leaving with changes asks first (ConfirmScreen).
|
||||
use horizon_client::{
|
||||
//! AbstractGameRulesScreen: the rules by category under a search box, a
|
||||
//! switch or a number box each. InWorldGameRulesScreen, opened from World
|
||||
//! Options, sends the changed ones on Done (ServerboundSetGameRulePacket) and
|
||||
//! asks before leaving with changes (ConfirmScreen); WorldCreationGameRulesScreen,
|
||||
//! opened from Create New World, hands them back and leaves without asking.
|
||||
use crate::{
|
||||
confirm_screen::Confirm,
|
||||
font::Font,
|
||||
gamerules::{self, GameRules, RULES, Rule, Value},
|
||||
@@ -32,14 +33,14 @@ enum Row {
|
||||
|
||||
/// What a click or Escape asks of World Options.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) enum Exit {
|
||||
pub enum Exit {
|
||||
/// Done: the rules that changed.
|
||||
Apply(Vec<(&'static str, Value)>),
|
||||
/// Cancel, or discarding the changes.
|
||||
Discard,
|
||||
}
|
||||
|
||||
pub(super) struct RulesScreen {
|
||||
pub struct RulesScreen {
|
||||
search: EditBox,
|
||||
values: GameRules,
|
||||
initial: GameRules,
|
||||
@@ -49,6 +50,8 @@ pub(super) struct RulesScreen {
|
||||
scroll: ui::Scroll,
|
||||
/// ConfirmScreen "Game Rule Changes" in its place.
|
||||
confirm: bool,
|
||||
/// InWorldGameRulesScreen rather than WorldCreationGameRulesScreen.
|
||||
in_world: bool,
|
||||
}
|
||||
|
||||
fn name_key(r: &Rule) -> String {
|
||||
@@ -102,7 +105,15 @@ fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
|
||||
}
|
||||
|
||||
impl RulesScreen {
|
||||
pub(super) fn new(rules: &GameRules) -> Self {
|
||||
/// InWorldGameRulesScreen over the world's rules.
|
||||
pub fn new(rules: &GameRules) -> Self {
|
||||
Self::build(rules, true)
|
||||
}
|
||||
/// WorldCreationGameRulesScreen over the new world's rules.
|
||||
pub fn for_creation(rules: &GameRules) -> Self {
|
||||
Self::build(rules, false)
|
||||
}
|
||||
fn build(rules: &GameRules, in_world: bool) -> Self {
|
||||
// setInitialFocus: the search box.
|
||||
let search = EditBox::new(0.0, 0.0, SEARCH_WIDTH, 15.0);
|
||||
let numbers = RULES
|
||||
@@ -123,6 +134,7 @@ impl RulesScreen {
|
||||
numbers,
|
||||
scroll: ui::Scroll::default(),
|
||||
confirm: false,
|
||||
in_world,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,9 +209,10 @@ impl RulesScreen {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// InWorldGameRulesScreen.onClose: pending changes ask first.
|
||||
/// InWorldGameRulesScreen.onClose: pending changes ask first;
|
||||
/// WorldCreationGameRulesScreen.onClose discards them.
|
||||
fn close(&mut self) -> Option<Exit> {
|
||||
if self.changes().is_empty() {
|
||||
if !self.in_world || self.changes().is_empty() {
|
||||
Some(Exit::Discard)
|
||||
} else {
|
||||
self.confirm = true;
|
||||
@@ -207,7 +220,7 @@ impl RulesScreen {
|
||||
}
|
||||
}
|
||||
/// Escape: ConfirmScreen answers no; the rules close.
|
||||
pub(super) fn escape(&mut self) -> Option<Exit> {
|
||||
pub fn escape(&mut self) -> Option<Exit> {
|
||||
if self.confirm {
|
||||
self.confirm = false;
|
||||
None
|
||||
@@ -216,7 +229,7 @@ impl RulesScreen {
|
||||
}
|
||||
}
|
||||
/// The mouse wheel (AbstractSelectionList: half an entry a step).
|
||||
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
|
||||
pub fn wheel(&mut self, delta: f32, h: f32) {
|
||||
if !self.confirm {
|
||||
let content = self.content();
|
||||
self.scroll
|
||||
@@ -224,20 +237,20 @@ impl RulesScreen {
|
||||
}
|
||||
}
|
||||
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
|
||||
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
|
||||
pub fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
|
||||
let content = self.content();
|
||||
self.scroll
|
||||
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
|
||||
}
|
||||
/// The pointer moved while the scrollbar is held.
|
||||
pub(super) fn drag(&mut self, y: f32, h: f32) {
|
||||
pub fn drag(&mut self, y: f32, h: f32) {
|
||||
if self.scroll.held() {
|
||||
let content = self.content();
|
||||
self.scroll.drag(y, HEADER, h - FOOTER, content);
|
||||
}
|
||||
}
|
||||
/// The button came up: the scrollbar lets go.
|
||||
pub(super) fn release(&mut self) {
|
||||
pub fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
}
|
||||
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
|
||||
@@ -272,7 +285,7 @@ impl RulesScreen {
|
||||
}
|
||||
}
|
||||
/// Run an edit on the focused box, then its responder.
|
||||
pub(super) fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
|
||||
pub fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
|
||||
let Some((rule, b)) = self.focused() else {
|
||||
return;
|
||||
};
|
||||
@@ -282,12 +295,12 @@ impl RulesScreen {
|
||||
self.edited(rule);
|
||||
}
|
||||
}
|
||||
pub(super) fn typing(&self) -> bool {
|
||||
pub fn typing(&self) -> bool {
|
||||
!self.confirm && (self.search.focused || self.numbers.iter().any(|(_, b)| b.focused))
|
||||
}
|
||||
|
||||
/// A left click; Some when the screen closes.
|
||||
pub(super) fn click(
|
||||
pub fn click(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
@@ -355,12 +368,7 @@ impl RulesScreen {
|
||||
}
|
||||
}
|
||||
/// Whether a click lands on a button (for its sound).
|
||||
pub(super) fn clicks_button(
|
||||
&self,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
size: (f32, f32),
|
||||
) -> bool {
|
||||
pub fn clicks_button(&self, atlas: &HudAtlas, cursor: (f32, f32), size: (f32, f32)) -> bool {
|
||||
let (w, h) = size;
|
||||
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
|
||||
if self.confirm {
|
||||
@@ -377,9 +385,9 @@ impl RulesScreen {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
|
||||
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
if self.confirm {
|
||||
let labels = [
|
||||
Text::translate("gui.yes", &[]),
|
||||
@@ -393,7 +401,7 @@ impl RulesScreen {
|
||||
self.scroll.clamp(HEADER, h - FOOTER, content);
|
||||
// RuleList: the in-world list background, its entries clipped to it,
|
||||
// then the separators and scrollbar.
|
||||
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
|
||||
ui::list_background(batch, atlas, HEADER, h - FOOTER, self.in_world);
|
||||
let first = batch.vertices.len();
|
||||
let hovered = self.hovered(rows.len(), cursor, (w, h));
|
||||
let (left, x) = (row_left(w), control_x(w));
|
||||
@@ -450,7 +458,7 @@ impl RulesScreen {
|
||||
}
|
||||
}
|
||||
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
|
||||
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
|
||||
ui::separators(batch, atlas, HEADER, h - FOOTER, self.in_world);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -526,8 +534,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn box_colours_are_vanillas() {
|
||||
assert_eq!(VALID, horizon_client::hud::rgba(224, 224, 224, 255));
|
||||
assert_eq!(INVALID, horizon_client::hud::rgba(255, 0, 0, 255));
|
||||
assert_eq!(VALID, crate::hud::rgba(224, 224, 224, 255));
|
||||
assert_eq!(INVALID, crate::hud::rgba(255, 0, 0, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -37,7 +37,10 @@ mod crumbling_tests;
|
||||
mod entity_batch;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
|
||||
pub use entity_batch::{
|
||||
SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
|
||||
};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
pub fn outline_width(width: u32) -> f32 {
|
||||
@@ -247,6 +250,8 @@ pub struct Frame<'a> {
|
||||
pub outline: Vec<[f64; 6]>,
|
||||
/// `BlockOutlineRenderState.isTranslucent`: draw after translucent terrain.
|
||||
pub outline_after_terrain: bool,
|
||||
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
|
||||
pub outline_high_contrast: bool,
|
||||
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
|
||||
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
|
||||
/// over everything.
|
||||
@@ -377,6 +382,7 @@ pub struct Renderer {
|
||||
sky: wgpu::RenderPipeline,
|
||||
dark_disc: wgpu::RenderPipeline,
|
||||
lines: wgpu::RenderPipeline,
|
||||
lines_secondary: wgpu::RenderPipeline,
|
||||
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
|
||||
/// always-on-top ones.
|
||||
debug_lines: wgpu::RenderPipeline,
|
||||
@@ -391,6 +397,7 @@ pub struct Renderer {
|
||||
weather: weather::Weather,
|
||||
crumbling_pipeline: wgpu::RenderPipeline,
|
||||
shadow_blob_pipeline: wgpu::RenderPipeline,
|
||||
translucent_item_pipeline: wgpu::RenderPipeline,
|
||||
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
|
||||
water_mask_pipeline: wgpu::RenderPipeline,
|
||||
cloud_pipeline: wgpu::RenderPipeline,
|
||||
@@ -1213,12 +1220,6 @@ impl Renderer {
|
||||
attributes: &instance_attributes,
|
||||
}),
|
||||
];
|
||||
let line_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3];
|
||||
let line_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 24,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &line_attributes,
|
||||
})];
|
||||
let alpha_blend = wgpu::BlendState::ALPHA_BLENDING;
|
||||
let debug_line_attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32];
|
||||
@@ -1351,13 +1352,25 @@ impl Renderer {
|
||||
"Block outline",
|
||||
"vs_line",
|
||||
"fs_line",
|
||||
&line_buffers,
|
||||
&debug_line_buffers,
|
||||
wgpu::PrimitiveTopology::TriangleStrip,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
// RenderTypes.secondaryBlockOutline: the black stroke under a high contrast outline.
|
||||
let lines_secondary = pipeline(
|
||||
"Block outline secondary",
|
||||
"vs_line",
|
||||
"fs_line",
|
||||
&debug_line_buffers,
|
||||
wgpu::PrimitiveTopology::TriangleStrip,
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
let debug_lines = pipeline(
|
||||
"Debug lines",
|
||||
"vs_debug_line",
|
||||
@@ -1531,7 +1544,7 @@ impl Renderer {
|
||||
});
|
||||
let outline = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Outline"),
|
||||
size: 24 * MAX_OUTLINE_EDGES,
|
||||
size: 32 * 2 * MAX_OUTLINE_EDGES,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -1818,25 +1831,40 @@ impl Renderer {
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.ENTITY_SHADOW: translucent, depth test without
|
||||
// writes. The quads lie on the block tops like vanilla's, but the
|
||||
// terrain under them snaps to the subpixel grid at other vertices, so
|
||||
// at a slant their depths differ by up to 1/256 of a pixel's depth
|
||||
// slope and whole shadows failed the test. The bias covers that and
|
||||
// the rounding, and stays below the 0.001 a model's feet sit higher
|
||||
// (1/27 of a pixel's slope at the 16-block shadow range in 720p).
|
||||
// writes. The quads lie exactly on the block tops like vanilla's, but
|
||||
// terrain and shadows are separate draw paths, so their fp32 depth
|
||||
// interpolations disagree by a few dozen ulps of the fragment depth
|
||||
// and the shadow's rim z-fights with the block top (speckle that
|
||||
// re-rolls as the camera drifts a subpixel). A constant bias of 128
|
||||
// ulps covers that spread at any resolution while staying far below
|
||||
// the 0.001 a model's feet sit higher, so shadows never bleed onto
|
||||
// the feet.
|
||||
let shadow_blob_pipeline = entity_pipeline(
|
||||
"Entity shadows",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
constant: 16,
|
||||
constant: 128,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
},
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
|
||||
// translucent terrain, it cannot write depth: the water behind a
|
||||
// glass item would fail the test and vanish (vanilla sorts these in
|
||||
// its order independent transparency pass instead).
|
||||
let translucent_item_pipeline = entity_pipeline(
|
||||
"Translucent items",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
Default::default(),
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let cloud_pipeline = entity_pipeline(
|
||||
"Clouds",
|
||||
Some(wgpu::Face::Back),
|
||||
@@ -2362,6 +2390,7 @@ impl Renderer {
|
||||
sky,
|
||||
dark_disc,
|
||||
lines,
|
||||
lines_secondary,
|
||||
debug_lines,
|
||||
debug_lines_on_top,
|
||||
debug_line_buffer,
|
||||
@@ -2372,6 +2401,7 @@ impl Renderer {
|
||||
weather,
|
||||
crumbling_pipeline,
|
||||
shadow_blob_pipeline,
|
||||
translucent_item_pipeline,
|
||||
water_mask_pipeline,
|
||||
cloud_pipeline,
|
||||
cloud_mesh: Default::default(),
|
||||
@@ -2483,6 +2513,18 @@ impl Renderer {
|
||||
present_modes,
|
||||
})
|
||||
}
|
||||
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
|
||||
/// secondary stroke first when high contrast is on.
|
||||
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
|
||||
pass.set_vertex_buffer(0, self.outline.slice(..));
|
||||
if high_contrast {
|
||||
pass.set_pipeline(&self.lines_secondary);
|
||||
pass.draw(0..4, 0..edges);
|
||||
}
|
||||
pass.set_pipeline(&self.lines);
|
||||
let first = if high_contrast { edges } else { 0 };
|
||||
pass.draw(0..4, first..first + edges);
|
||||
}
|
||||
fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
|
||||
let indices: Vec<u32> = (0..quads)
|
||||
.flat_map(|q| {
|
||||
@@ -3195,7 +3237,7 @@ impl Renderer {
|
||||
/// The Mipmap Levels option: block textures sample at most `levels`
|
||||
/// reduced copies (0: none).
|
||||
pub fn set_mipmap_levels(&mut self, levels: u32) {
|
||||
let levels = sprite_mipmap_limit(
|
||||
let levels = crate::sprites::sprite_mipmap_limit(
|
||||
levels,
|
||||
self.sprite_textures[0].width(),
|
||||
self.sprite_minimum_size,
|
||||
@@ -3955,7 +3997,26 @@ impl Renderer {
|
||||
);
|
||||
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
|
||||
if outline_edges > 0 {
|
||||
let lines = outline_vertices(&frame.outline[..outline_edges], camera);
|
||||
// The same per-line colour and pixel width layout as the gizmo lines. A high
|
||||
// contrast outline is two strokes: the black one first, the bright one after it.
|
||||
let edges = outline_vertices(&frame.outline[..outline_edges], camera);
|
||||
let width = outline_width(self.config.width);
|
||||
let stroke = |color: u32, width: f32| {
|
||||
edges.iter().map(move |e| {
|
||||
let mut line = [0.0; 8];
|
||||
line[..6].copy_from_slice(e);
|
||||
line[6] = f32::from_bits(color);
|
||||
line[7] = width;
|
||||
line
|
||||
})
|
||||
};
|
||||
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
|
||||
stroke(0xff00_0000, 7.0)
|
||||
.chain(stroke(0xff57_ffe1, width))
|
||||
.collect()
|
||||
} else {
|
||||
stroke(0x6600_0000, width).collect()
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
|
||||
}
|
||||
@@ -3982,6 +4043,7 @@ impl Renderer {
|
||||
water_mask_quads,
|
||||
shadow_quads,
|
||||
translucent_particle_quads,
|
||||
translucent_item_quads,
|
||||
] = self
|
||||
.entity_batch
|
||||
.update(entities, frame.additive, frame.hand, frame.see_through);
|
||||
@@ -3991,7 +4053,8 @@ impl Renderer {
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads;
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
@@ -4514,6 +4577,17 @@ impl Renderer {
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 {
|
||||
pass.set_pipeline(&self.translucent_item_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_item_quads * 6,
|
||||
((world_quads - translucent_item_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !pack_draws_weather {
|
||||
self.weather.draw(&mut pass, &self.entity_group);
|
||||
}
|
||||
@@ -4531,7 +4605,11 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..shadow_quads * 6,
|
||||
((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32,
|
||||
((world_quads
|
||||
- translucent_item_quads
|
||||
- translucent_particle_quads
|
||||
- shadow_quads)
|
||||
* 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -4563,9 +4641,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if outline_edges > 0 && !frame.outline_after_terrain {
|
||||
pass.set_pipeline(&self.lines);
|
||||
pass.set_vertex_buffer(0, self.outline.slice(..));
|
||||
pass.draw(0..4, 0..outline_edges as u32);
|
||||
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
|
||||
}
|
||||
if debug_depth > 0 {
|
||||
pass.set_pipeline(&self.debug_lines);
|
||||
@@ -4612,7 +4688,8 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_particle_quads * 6,
|
||||
((world_quads - translucent_particle_quads) * 4) as i32,
|
||||
((world_quads - translucent_item_quads - translucent_particle_quads) * 4)
|
||||
as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -4620,9 +4697,7 @@ impl Renderer {
|
||||
// LevelRenderer.executeClassicTransparency: translucent targets'
|
||||
// outlines follow terrain, so its blending cannot wash them out.
|
||||
if outline_edges > 0 && frame.outline_after_terrain {
|
||||
pass.set_pipeline(&self.lines);
|
||||
pass.set_vertex_buffer(0, self.outline.slice(..));
|
||||
pass.draw(0..4, 0..outline_edges as u32);
|
||||
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
// LevelRenderer: the world border after translucent terrain and weather.
|
||||
@@ -5241,7 +5316,7 @@ mod tests {
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 0, 1, 1]);
|
||||
assert_eq!(counts, [4, 1, 1, 0, 1, 1, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -5477,11 +5552,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, available: u32) -> u32 {
|
||||
// Only the atlas native minimum determines how many resampling-only mips to skip.
|
||||
let upscale = (size / minimum.max(1)).max(1).ilog2();
|
||||
requested.saturating_add(upscale).min(available - 1)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod sprite_upload_tests {
|
||||
use super::*;
|
||||
@@ -5542,13 +5612,6 @@ mod sprite_upload_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn mipmap_option_keeps_real_16px_reductions() {
|
||||
for levels in 0..=4 {
|
||||
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
|
||||
assert_eq!(sprite_mipmap_limit(levels, 16, 16, 5), levels);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn prepared_texture_uploads_preserve_layer_order_and_settings() {
|
||||
let size = 4;
|
||||
let layers = 5;
|
||||
|
||||
@@ -5,14 +5,20 @@ use super::Vertex;
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows
|
||||
/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15,
|
||||
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`).
|
||||
const MATERIALS: usize = 6;
|
||||
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
|
||||
/// and translucent item quads (32, drawn without depth writes).
|
||||
const MATERIALS: usize = 7;
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
|
||||
pub const SHADOW_OVERLAY: u32 = 14;
|
||||
/// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`).
|
||||
pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
|
||||
/// The overlay of item quads with translucent pixels (`Sheets.translucentItemSheet`,
|
||||
/// `RenderPipelines.ITEM_TRANSLUCENT`); past the creeper swell range (16 to 31).
|
||||
pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
|
||||
/// A translucent item with an enchantment glint.
|
||||
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EntityBatch {
|
||||
@@ -45,6 +51,7 @@ impl EntityBatch {
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
SHADOW_OVERLAY => 4,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 5,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 6,
|
||||
_ => 0,
|
||||
};
|
||||
self.buckets[material][counts[material]] = index;
|
||||
|
||||
@@ -204,6 +204,19 @@ impl Gizmos {
|
||||
position[1] + f64::from(size.1),
|
||||
position[2] + half,
|
||||
];
|
||||
self.entity_box(min, max, position, eye_height, living, view);
|
||||
}
|
||||
/// The same for an entity whose box is not centred on its position
|
||||
/// (a painting or an item frame): `min` and `max` are the box.
|
||||
pub fn entity_box(
|
||||
&mut self,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
position: [f64; 3],
|
||||
eye_height: f32,
|
||||
living: bool,
|
||||
view: (f32, f32),
|
||||
) {
|
||||
self.cuboid(min, max, WHITE, STROKE_WIDTH);
|
||||
if living {
|
||||
let eye = min[1] + f64::from(eye_height);
|
||||
|
||||
@@ -32,14 +32,15 @@ const STRENGTH: f32 = 0.7;
|
||||
|
||||
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
|
||||
// across its channels, as the vignette and other screen-wide tints darken it.
|
||||
// The hand, HUD and text replace pixels instead, so they never pass.
|
||||
// The hand, HUD and text replace pixels instead, so they never pass. The
|
||||
// channels are held to one factor, the ratio of their sums, so a channel at 0
|
||||
// stays 0; a step of 8-bit rounding is allowed on each.
|
||||
fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
if all(c == w) {
|
||||
return true;
|
||||
}
|
||||
let k = c / max(w, vec3(1.0 / 255.0));
|
||||
let mean = (k.r + k.g + k.b) / 3.0;
|
||||
return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03));
|
||||
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
@@ -69,7 +70,10 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
|
||||
let y = 0.5 * (dot(light, vec3(0.2126, 0.7152, 0.0722)) + max(light.r, max(light.g, light.b)));
|
||||
// Brightness of the world pixel, before the vignette darkens the frame
|
||||
// toward its edges: a light source shines the same anywhere on screen.
|
||||
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
|
||||
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
|
||||
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
|
||||
@@ -29,12 +29,22 @@ pub(crate) type State = HurtState<DamageTypeId>;
|
||||
pub struct HealthRules {
|
||||
pub difficulty: Difficulty,
|
||||
pub natural_regeneration: bool,
|
||||
/// The `drowning_damage`, `fall_damage`, `fire_damage` and
|
||||
/// `freeze_damage` rules `Player.isInvulnerableTo` reads.
|
||||
pub drowning_damage: bool,
|
||||
pub fall_damage: bool,
|
||||
pub fire_damage: bool,
|
||||
pub freeze_damage: bool,
|
||||
}
|
||||
impl Default for HealthRules {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
difficulty: Difficulty::Normal,
|
||||
natural_regeneration: true,
|
||||
drowning_damage: true,
|
||||
fall_damage: true,
|
||||
fire_damage: true,
|
||||
freeze_damage: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -633,8 +643,13 @@ impl ImmunityWorld for Environment<'_> {
|
||||
fn enchantment_immunity(&mut self) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn damage_rule(&mut self, _: DamageRule) -> io::Result<bool> {
|
||||
Ok(true)
|
||||
fn damage_rule(&mut self, rule: DamageRule) -> io::Result<bool> {
|
||||
Ok(match rule {
|
||||
DamageRule::Drowning => self.rules.drowning_damage,
|
||||
DamageRule::Fall => self.rules.fall_damage,
|
||||
DamageRule::Fire => self.rules.fire_damage,
|
||||
DamageRule::Freeze => self.rules.freeze_damage,
|
||||
})
|
||||
}
|
||||
fn changing_dimension(&mut self) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
@@ -860,6 +875,31 @@ mod tests {
|
||||
assert!(!third.hurt && !third.knockback);
|
||||
}
|
||||
|
||||
/// #2141: an arrow that takes the last of the player's health must kill
|
||||
/// them: `LivingEntity.hurt` leaves the health at zero and `Player.die`
|
||||
/// follows.
|
||||
#[test]
|
||||
fn an_arrow_kills_a_player_at_one_health() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let resources = crate::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let mut player =
|
||||
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.unwrap();
|
||||
player.set_health(1.0, false).unwrap();
|
||||
let (health, rules) = (&resources.health, super::HealthRules::default());
|
||||
let id = health.damage.id("minecraft:arrow").unwrap();
|
||||
let hurt = player.hurt(health, rules, id, 5.0).unwrap();
|
||||
assert!(hurt, "the arrow must hurt");
|
||||
assert_eq!(player.health.health(), 0.0, "the health must be spent");
|
||||
assert!(player.health.dead_or_dying(), "the player must be dead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hurt_sounds_follow_damage_effects() {
|
||||
// DamageEffects: HURT, THORNS, DROWNING, BURNING, POKING, FREEZING.
|
||||
|
||||
@@ -912,6 +912,8 @@ impl HudBatch {
|
||||
// StringDecomposer.iterateFormatted inside Font.drawInternal:
|
||||
// `§` codes inside a span restyle what follows and are not drawn.
|
||||
let text = text.resolved();
|
||||
// Language.getVisualOrder: shaped and reordered for right-to-left text.
|
||||
let text = crate::bidi::visual(&text);
|
||||
let font = &atlas.font;
|
||||
let alpha = (color >> 24) as u8;
|
||||
let mut glyphs = Vec::new();
|
||||
|
||||
@@ -46,6 +46,9 @@ pub struct ItemQuad {
|
||||
pub foil: Foil,
|
||||
/// `MaterialInfo.lightEmission` (block light floor).
|
||||
pub light_emission: u8,
|
||||
/// The quad's sprite has translucent pixels (`MaterialInfo.itemRenderType`
|
||||
/// is a translucent sheet): drawn with the translucent entities.
|
||||
pub translucent: bool,
|
||||
}
|
||||
/// Which glint render type a quad uses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -159,7 +162,7 @@ impl ItemRenderState {
|
||||
let m = pose * layer.matrix;
|
||||
match &layer.geometry {
|
||||
LayerGeometry::Quads(baked) => {
|
||||
submit_quads(baked, layer, self.display, m, sprites, sink);
|
||||
submit_quads(renderer, baked, layer, self.display, m, sprites, sink);
|
||||
}
|
||||
LayerGeometry::Special { model, argument } => special::submit(
|
||||
renderer,
|
||||
@@ -203,6 +206,7 @@ pub fn argb(color: i32) -> u32 {
|
||||
|
||||
/// `ItemFeatureRenderer.prepareMainSubmit` for one cuboid layer.
|
||||
fn submit_quads(
|
||||
renderer: &ItemRenderer,
|
||||
baked: &BakedItemModel,
|
||||
layer: &LayerRender,
|
||||
display: DisplayContext,
|
||||
@@ -216,6 +220,8 @@ fn submit_quads(
|
||||
FoilType::Special => Foil::Special,
|
||||
};
|
||||
let _ = display;
|
||||
// Quads of one layer mostly share a sprite: look its translucency up once.
|
||||
let mut last: Option<(&str, bool)> = None;
|
||||
for q in &baked.quads {
|
||||
let Some(r) = sprites
|
||||
.get(sprite_name(&q.material.sprite))
|
||||
@@ -239,6 +245,16 @@ fn submit_quads(
|
||||
let pos = q.positions.map(|p| m.transform_point3(Vec3::from(p)));
|
||||
let uv =
|
||||
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v]);
|
||||
let translucent = match last {
|
||||
Some((sprite, translucent)) if sprite == q.material.sprite => translucent,
|
||||
_ => {
|
||||
let translucent = renderer
|
||||
.sprites
|
||||
.translucent(sprite_name(&q.material.sprite));
|
||||
last = Some((&q.material.sprite, translucent));
|
||||
translucent
|
||||
}
|
||||
};
|
||||
sink.quad(ItemQuad {
|
||||
pos,
|
||||
uv,
|
||||
@@ -246,6 +262,7 @@ fn submit_quads(
|
||||
color: argb(color),
|
||||
foil,
|
||||
light_emission: q.light_emission,
|
||||
translucent: translucent || q.material.force_translucent,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -260,6 +277,9 @@ struct FileSprites {
|
||||
root: PathBuf,
|
||||
permutations: Vec<PalettedPermutations>,
|
||||
cache: Mutex<HashMap<String, Option<SpriteMask>>>,
|
||||
/// Sprites with a pixel that is neither fully transparent nor opaque
|
||||
/// (`Transparency.hasTranslucent`), filled in as sprites load.
|
||||
translucent: Mutex<HashMap<String, bool>>,
|
||||
}
|
||||
impl SpriteLookup for FileSprites {
|
||||
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
@@ -283,6 +303,13 @@ fn argb_pixels(rgba: &[u8]) -> Vec<u32> {
|
||||
.collect()
|
||||
}
|
||||
impl FileSprites {
|
||||
fn translucent(&self, sprite: &str) -> bool {
|
||||
let known = |this: &Self| this.translucent.lock().ok()?.get(sprite).copied();
|
||||
known(self).unwrap_or_else(|| {
|
||||
self.mask(sprite);
|
||||
known(self).unwrap_or(false)
|
||||
})
|
||||
}
|
||||
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
|
||||
crate::atlas_sources::texture_path(&self.root, sprite, directory)
|
||||
}
|
||||
@@ -303,6 +330,10 @@ impl FileSprites {
|
||||
(w, h, pixels)
|
||||
}
|
||||
};
|
||||
let translucent = argb.iter().any(|p| matches!(p >> 24, 1..=254));
|
||||
if let Ok(mut cache) = self.translucent.lock() {
|
||||
cache.insert(sprite.to_owned(), translucent);
|
||||
}
|
||||
let alpha: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
|
||||
let animation = fs::read_to_string(png.with_extension("png.mcmeta"))
|
||||
.ok()
|
||||
@@ -452,6 +483,7 @@ impl ItemRenderer {
|
||||
root: root.to_path_buf(),
|
||||
permutations,
|
||||
cache: Mutex::default(),
|
||||
translucent: Mutex::default(),
|
||||
},
|
||||
baked: Mutex::default(),
|
||||
extents: Mutex::default(),
|
||||
@@ -694,7 +726,12 @@ impl ItemSink for WorldSink<'_> {
|
||||
} else {
|
||||
self.light
|
||||
};
|
||||
let overlay = if q.foil == Foil::None { 0 } else { 7 };
|
||||
let overlay = match (q.foil, q.translucent) {
|
||||
(Foil::None, true) => crate::gfx::TRANSLUCENT_ITEM_OVERLAY,
|
||||
(Foil::None, false) => 0,
|
||||
(_, true) => crate::gfx::TRANSLUCENT_GLINT_ITEM_OVERLAY,
|
||||
(_, false) => 7,
|
||||
};
|
||||
for (p, uv) in q.pos.iter().zip(q.uv) {
|
||||
self.out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
|
||||
@@ -358,6 +358,7 @@ mod tests {
|
||||
color,
|
||||
foil: Foil::None,
|
||||
light_emission: 0,
|
||||
translucent: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ fn draw(
|
||||
color,
|
||||
foil,
|
||||
light_emission: 0,
|
||||
translucent: false,
|
||||
});
|
||||
}
|
||||
for (child_name, child) in &part.children {
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
//! player state (inventory, health, game mode) that the host's gameplay code
|
||||
//! operates on when it acts for them.
|
||||
use super::{Joined, PlayerSender, entity::Tracker};
|
||||
use crate::{items::ItemStack, motion::PlayerMotion};
|
||||
use crate::{
|
||||
items::ItemStack,
|
||||
motion::PlayerMotion,
|
||||
pose::{Dimensions, Pose},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::{
|
||||
item::Stack,
|
||||
packets::{configuration::ClientInformation, play},
|
||||
@@ -407,8 +412,41 @@ impl Guest {
|
||||
}
|
||||
f
|
||||
}
|
||||
/// `Player.getDesiredPose`: sleeping, gliding, then the body's own pose
|
||||
/// (swimming, crawling, crouching) once its server tick has run.
|
||||
pub fn pose(&self) -> Pose {
|
||||
if self.sleeping {
|
||||
Pose::Sleeping
|
||||
} else if self.fall_flying {
|
||||
Pose::FallFlying
|
||||
} else if let Some(m) = &self.motion {
|
||||
m.pose
|
||||
} else if self.sneaking && !self.flying {
|
||||
Pose::Crouching
|
||||
} else {
|
||||
Pose::Standing
|
||||
}
|
||||
}
|
||||
/// `Avatar.POSES` for the guest's pose, with its scale attribute.
|
||||
pub fn dimensions(&self) -> Dimensions {
|
||||
let pose = self.pose();
|
||||
match &self.motion {
|
||||
Some(m) if m.pose == pose => m.dimensions,
|
||||
_ => pose.dimensions(1.0),
|
||||
}
|
||||
}
|
||||
/// `Entity.getBoundingBox`.
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let [x, y, z] = self.position;
|
||||
let d = self.dimensions();
|
||||
let half = f64::from(d.width) / 2.0;
|
||||
Aabb::new(
|
||||
Vec3::new(x - half, y, z - half),
|
||||
Vec3::new(x + half, y + f64::from(d.height), z + half),
|
||||
)
|
||||
}
|
||||
pub fn eye_height(&self) -> f64 {
|
||||
if self.sneaking { 1.27 } else { 1.62 }
|
||||
f64::from(self.dimensions().eye_height)
|
||||
}
|
||||
/// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest:
|
||||
/// its block interaction range is 4.5, 5 in creative.
|
||||
|
||||
@@ -2344,18 +2344,8 @@ impl Hub {
|
||||
}
|
||||
// getBoundingBox: the box of the body's pose (swimming and crawling
|
||||
// are 0.6 tall), so a swimmer under a ceiling isn't sent back.
|
||||
let (half, height) = if g.fall_flying {
|
||||
(0.3, 0.6)
|
||||
} else if let Some(m) = &g.motion {
|
||||
(
|
||||
f64::from(m.dimensions.width) / 2.0,
|
||||
f64::from(m.dimensions.height),
|
||||
)
|
||||
} else if g.sneaking {
|
||||
(0.3, 1.5)
|
||||
} else {
|
||||
(0.3, 1.8)
|
||||
};
|
||||
let d = g.dimensions();
|
||||
let (half, height) = (f64::from(d.width) / 2.0, f64::from(d.height));
|
||||
let at = |p: [f64; 3]| {
|
||||
Aabb::new(
|
||||
Vec3::new(p[0] - half, p[1], p[2] - half),
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod auto_jump;
|
||||
pub mod banner;
|
||||
pub mod beehive;
|
||||
pub mod benchmark;
|
||||
pub mod bidi;
|
||||
pub mod biome;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
@@ -74,6 +75,7 @@ pub mod fog;
|
||||
pub mod font;
|
||||
pub mod friends_screen;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
pub mod gamepad;
|
||||
pub mod gfx;
|
||||
pub mod gizmos;
|
||||
|
||||
@@ -527,6 +527,8 @@ pub enum DamageKind {
|
||||
InWall,
|
||||
Thorns,
|
||||
SonicBoom,
|
||||
/// `freeze`: a fully frozen mob in powder snow.
|
||||
Freeze,
|
||||
/// `fell_out_of_world`: below the world's floor.
|
||||
OutOfWorld,
|
||||
}
|
||||
@@ -596,6 +598,7 @@ impl Damage {
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Generic
|
||||
| DamageKind::SonicBoom
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
)
|
||||
}
|
||||
@@ -620,6 +623,7 @@ impl Damage {
|
||||
| DamageKind::Cactus
|
||||
| DamageKind::Generic
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
)
|
||||
}
|
||||
@@ -646,6 +650,9 @@ pub struct Attrs {
|
||||
pub struct Extra {
|
||||
/// Players: crouching and riding (HumanoidModel's poses).
|
||||
pub crouching: bool,
|
||||
/// A player's or mannequin's synced pose (`Entity.DATA_POSE`), which
|
||||
/// sets its box (`Avatar.POSES`).
|
||||
pub avatar_pose: crate::pose::Pose,
|
||||
/// Players: sprinting (`Entity.DATA_SHARED_FLAGS_ID`), for the dust they raise.
|
||||
pub sprinting: bool,
|
||||
pub passenger: bool,
|
||||
@@ -904,6 +911,12 @@ pub enum MobEvent {
|
||||
mob: u32,
|
||||
at: [f64; 3],
|
||||
},
|
||||
/// `LivingEntity.baseTick`'s drowning damage: entity event 67, the
|
||||
/// clients' `makeDrownParticles`.
|
||||
Drown {
|
||||
mob: u32,
|
||||
at: [f64; 3],
|
||||
},
|
||||
Sound(&'static str, [f64; 3], f32, f32),
|
||||
/// `ChestBlockEntity.startOpen` / `stopOpen` by a copper golem: the
|
||||
/// chest's opener count goes up or down (interaction range 3).
|
||||
@@ -1307,6 +1320,8 @@ pub struct Seen {
|
||||
pub active_raid: bool,
|
||||
/// A copper golem's `openedChestPos` (`ContainerUser.hasContainerOpen`).
|
||||
pub opened_chest: Option<[i32; 3]>,
|
||||
/// `isPassenger`.
|
||||
pub passenger: bool,
|
||||
/// A villager's reputation of the player (`getPlayerReputation`).
|
||||
pub player_rep: i32,
|
||||
/// `isPushable` and pushed by others (`Entity.push(Entity)` moves it).
|
||||
@@ -1355,6 +1370,10 @@ impl Mob {
|
||||
.max((f64::from(ticks) * self.burning_time()).ceil() as i32);
|
||||
self.ticks_frozen = 0;
|
||||
}
|
||||
/// `Entity.isFullyFrozen`.
|
||||
pub fn is_fully_frozen(&self) -> bool {
|
||||
self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE
|
||||
}
|
||||
|
||||
/// What other mobs see of this one this tick (`player`: the player's
|
||||
/// UUID, which villagers' reputation is keyed by).
|
||||
@@ -1394,6 +1413,7 @@ impl Mob {
|
||||
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
|
||||
active_raid: raider::has_active_raid(m),
|
||||
opened_chest: m.extra.opened_chest,
|
||||
passenger: m.extra.mount.is_some(),
|
||||
player_rep: match (villager, player) {
|
||||
(true, Some(uuid)) => villager::ext(m).map_or(0, |e| e.gossips.reputation(uuid)),
|
||||
_ => 0,
|
||||
@@ -1573,6 +1593,8 @@ pub struct PlayerView {
|
||||
/// A LAN guest: its own client moves it, and touching mobs push it there
|
||||
/// (`EntitySelector.pushableBy` on the client).
|
||||
pub remote: bool,
|
||||
/// `isPassenger`.
|
||||
pub passenger: bool,
|
||||
}
|
||||
impl PlayerView {
|
||||
/// `LivingEntity.hasEffect`, by id without namespace.
|
||||
@@ -1833,6 +1855,15 @@ impl Mob {
|
||||
{
|
||||
amount *= 0.75;
|
||||
}
|
||||
// LivingEntity.hurtServer: `#freeze_hurts_extra_types` take five times.
|
||||
if source.kind == DamageKind::Freeze
|
||||
&& matches!(
|
||||
self.species(),
|
||||
Species::Strider | Species::Blaze | Species::MagmaCube
|
||||
)
|
||||
{
|
||||
amount *= 5.0;
|
||||
}
|
||||
let (worn, worn_toughness) = self.worn_armor();
|
||||
if !source.bypasses_armor() && self.kind.armor + worn > 0.0 {
|
||||
// CombatRules.getDamageAfterAbsorb
|
||||
@@ -1894,7 +1925,7 @@ impl Mob {
|
||||
let dead = !self.alive();
|
||||
let sound = ai::voice_sounds(self)[if dead { 2 } else { 1 }];
|
||||
if !dead {
|
||||
self.ambient_timer = -self.kind.ambient_interval;
|
||||
self.ambient_timer = -self.ambient_interval();
|
||||
}
|
||||
if !sound.is_empty() {
|
||||
let pitch = self.voice_pitch();
|
||||
@@ -2004,10 +2035,30 @@ impl Mob {
|
||||
let at = [self.position.x, self.eye_y(), self.position.z];
|
||||
events.push(MobEvent::Sound(event, at, 1.0, 1.0));
|
||||
}
|
||||
/// `Mob.getAmbientSoundInterval`; `HappyGhast` waits six times as long
|
||||
/// while it carries a passenger.
|
||||
pub fn ambient_interval(&self) -> i32 {
|
||||
let ridden = self.rider.is_some() || self.extra.rider.is_some();
|
||||
if self.species() == Species::HappyGhast && ridden {
|
||||
self.kind.ambient_interval * 6
|
||||
} else {
|
||||
self.kind.ambient_interval
|
||||
}
|
||||
}
|
||||
/// LivingEntity.getVoicePitch.
|
||||
pub fn voice_pitch(&mut self) -> f32 {
|
||||
// HappyGhast.getVoicePitch: always 1.0, babies and all.
|
||||
if self.species() == Species::HappyGhast {
|
||||
return 1.0;
|
||||
}
|
||||
// ZombieVillager.getVoicePitch: babies are 2.0, not 1.5.
|
||||
let baby = if self.species() == Species::ZombieVillager {
|
||||
2.0
|
||||
} else {
|
||||
1.5
|
||||
};
|
||||
let pitch = (self.random.next_float() - self.random.next_float()) * 0.2
|
||||
+ if self.is_baby() { 1.5 } else { 1.0 };
|
||||
+ if self.is_baby() { baby } else { 1.0 };
|
||||
// Bat.getVoicePitch: a little lower.
|
||||
if self.species() == Species::Bat {
|
||||
pitch * 0.95
|
||||
|
||||
@@ -185,9 +185,35 @@ pub fn register(m: &mut Mob) {
|
||||
if m.species() == Species::Rabbit {
|
||||
super::rabbit::register(m);
|
||||
}
|
||||
if m.species() == Species::Frog {
|
||||
// `Frog()`: SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true).
|
||||
// The speed attribute is 1.0, so the controller's scaling is
|
||||
// what keeps a frog at a walking pace on land.
|
||||
m.ai.mov.kind = super::control::MoveKind::SmoothSwimming {
|
||||
max_turn_x: 85.0,
|
||||
max_turn_y: 10.0,
|
||||
in_water: 0.02,
|
||||
outside: 0.1,
|
||||
gravity: true,
|
||||
};
|
||||
}
|
||||
if m.species() == Species::Turtle {
|
||||
super::turtle::register(m);
|
||||
}
|
||||
if m.species() == Species::Frog {
|
||||
// `Frog()`: SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true).
|
||||
// The frog's movement speed is 1.0, so the controller's 0.1
|
||||
// outside-water speed is what keeps a walking frog at
|
||||
// vanilla's pace; the plain ground controller walks ten
|
||||
// times too fast (#2213).
|
||||
m.ai.mov.kind = super::control::MoveKind::SmoothSwimming {
|
||||
max_turn_x: 85.0,
|
||||
max_turn_y: 10.0,
|
||||
in_water: 0.02,
|
||||
outside: 0.1,
|
||||
gravity: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
Family::Inert => {}
|
||||
Family::Witch => super::witch::register(m),
|
||||
@@ -307,6 +333,20 @@ fn generic(m: &mut Mob) {
|
||||
m.ai.targets.add(1, HurtByTarget::new());
|
||||
m.ai.targets.add(2, NearestTarget::player(true));
|
||||
} else {
|
||||
if m.kind.species == Species::Goat {
|
||||
// GoatAi's idle and core behaviours (the ram and long jump
|
||||
// activities are not here): panic 2.0, AnimalMakeLove, tempting
|
||||
// with goat_food and baby follow at 1.25, then stroll.
|
||||
g.add(0, Float);
|
||||
g.add(1, Panic::new(2.0));
|
||||
g.add(2, Breed::new(1.0));
|
||||
g.add(3, Tempt::new(1.25, m.kind.food.clone(), false));
|
||||
g.add(4, FollowParent::new(1.25));
|
||||
g.add(5, Stroll::avoiding_water(1.0));
|
||||
g.add(6, LookAtPlayer::new(6.0));
|
||||
g.add(7, RandomLookAround::default());
|
||||
return;
|
||||
}
|
||||
g.add(0, Float);
|
||||
let rabbit = m.kind.species == Species::Rabbit;
|
||||
g.add(1, Panic::new(if rabbit { 2.2 } else { 1.25 }));
|
||||
@@ -448,6 +488,16 @@ pub fn tick_swing(m: &mut Mob) {
|
||||
}
|
||||
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
|
||||
}
|
||||
/// `Entity.isInRain`: rain at the block position, or at the top of the box.
|
||||
pub(super) fn in_rain(m: &Mob, w: &MobWorld) -> bool {
|
||||
let (x, y, z) = (
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let top = (m.position.y + f64::from(m.height())).floor() as i32;
|
||||
w.raining_at(x, y, z) || w.raining_at(x, top, z)
|
||||
}
|
||||
/// `LivingEntity.baseTick` refills a breather's air by 4, then
|
||||
/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills
|
||||
/// it, anywhere else it runs down from the pre-tick value, and at -20 the
|
||||
@@ -458,15 +508,7 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
|
||||
if m.no_ai {
|
||||
return;
|
||||
}
|
||||
// Entity.isInRain: rain at the block, or at the top of the box.
|
||||
let (x, y, z) = (
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let top = (m.position.y + f64::from(m.height())).floor() as i32;
|
||||
let in_rain = w.raining_at(x, y, z) || w.raining_at(x, top, z);
|
||||
if m.fluids.in_water || in_rain {
|
||||
if m.fluids.in_water || in_rain(m, w) {
|
||||
m.air = super::axolotl::MAX_AIR;
|
||||
} else {
|
||||
m.air = before - 1;
|
||||
@@ -494,8 +536,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
);
|
||||
m.damage(fire, Damage::of(DamageKind::InFire));
|
||||
}
|
||||
// Fire: water extinguishes, lava ignites, burning hurts every second.
|
||||
if m.fluids.in_water && m.fire_ticks > 0 {
|
||||
// Fire: water or rain (Entity.isInRain) extinguishes, lava ignites,
|
||||
// burning hurts every second.
|
||||
if m.fire_ticks > 0 && (m.fluids.in_water || in_rain(m, w)) {
|
||||
m.fire_ticks = 0;
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.generic.extinguish_fire",
|
||||
@@ -524,14 +567,19 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
}
|
||||
m.first_tick = false;
|
||||
if m.alive() {
|
||||
// LivingEntity.baseTick: suffocation inside full blocks.
|
||||
// LivingEntity.baseTick: suffocation inside full blocks. Entity.isInWall
|
||||
// is false under noPhysics, which Vex.tick holds while it ticks.
|
||||
let (ex, ey, ez) = (
|
||||
m.position.x.floor() as i32,
|
||||
m.eye_y().floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let at_eye = w.facts.get(w.world.block(ex, ey, ez));
|
||||
if at_eye.full_collision && at_eye.solid_render && m.width() < 1.0 {
|
||||
if at_eye.full_collision
|
||||
&& at_eye.solid_render
|
||||
&& m.width() < 1.0
|
||||
&& m.species() != Species::Vex
|
||||
{
|
||||
m.damage(1.0, Damage::of(DamageKind::InWall));
|
||||
}
|
||||
// Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs).
|
||||
@@ -551,17 +599,20 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.air -= 1;
|
||||
if m.air == -20 {
|
||||
m.air = 0;
|
||||
events.push(MobEvent::Drown {
|
||||
mob: m.id,
|
||||
at: [m.position.x, m.position.y, m.position.z],
|
||||
});
|
||||
m.damage(2.0, Damage::of(DamageKind::Drown));
|
||||
}
|
||||
} else if m.air < 300 {
|
||||
m.air = (m.air + 4).min(300);
|
||||
}
|
||||
|
||||
if m.kind.flags.water_sensitive
|
||||
&& (m.fluids.in_water || w.raining && w.can_see_sky(ex, ey, ez))
|
||||
{
|
||||
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
|
||||
m.damage(1.0, Damage::of(DamageKind::Drown));
|
||||
}
|
||||
freeze_tick(m, w);
|
||||
}
|
||||
tick_effects(m);
|
||||
if m.species() == Species::Illusioner {
|
||||
@@ -584,13 +635,64 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let t = m.ambient_timer;
|
||||
m.ambient_timer += 1;
|
||||
if roll < t {
|
||||
m.ambient_timer = -m.kind.ambient_interval;
|
||||
m.ambient_timer = -m.ambient_interval();
|
||||
play_ambient(m, w, events);
|
||||
}
|
||||
}
|
||||
// Entity.baseTick ends with Leashable.tickLeash.
|
||||
super::leash::tick(m, w, events);
|
||||
}
|
||||
/// `LivingEntity.aiStep`'s "freezing" with `InsideBlockEffectType.FREEZE`:
|
||||
/// powder snow builds `ticks_frozen` up, anywhere else it melts twice as
|
||||
/// fast, and a fully frozen mob takes one freeze damage every 40 ticks.
|
||||
fn freeze_tick(m: &mut Mob, w: &MobWorld) {
|
||||
let in_snow = in_powder_snow(m, w);
|
||||
if !in_snow && m.ticks_frozen == 0 {
|
||||
return;
|
||||
}
|
||||
let can_freeze = can_freeze(m, w);
|
||||
if in_snow && can_freeze {
|
||||
m.ticks_frozen =
|
||||
(m.ticks_frozen + 1).min(crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE);
|
||||
} else {
|
||||
m.ticks_frozen = (m.ticks_frozen - 2).max(0);
|
||||
}
|
||||
if m.age.is_multiple_of(40) && m.is_fully_frozen() && can_freeze {
|
||||
m.damage(1.0, Damage::of(DamageKind::Freeze));
|
||||
}
|
||||
}
|
||||
/// Whether the mob's box (shrunk by `1.0E-5`, as in `Entity.checkInsideBlocks`)
|
||||
/// touches powder snow.
|
||||
fn in_powder_snow(m: &Mob, w: &MobWorld) -> bool {
|
||||
let b = m.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
|
||||
let (min, max) = (b.min_position(), b.max_position());
|
||||
for x in min.x.floor() as i32..=max.x.floor() as i32 {
|
||||
for y in min.y.floor() as i32..=max.y.floor() as i32 {
|
||||
for z in min.z.floor() as i32..=max.z.floor() as i32 {
|
||||
if &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:powder_snow" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `LivingEntity.canFreeze`: not a `#freeze_immune_entity_types` mob and no
|
||||
/// worn piece in `#freeze_immune_wearables`.
|
||||
fn can_freeze(m: &Mob, w: &MobWorld) -> bool {
|
||||
if w.data
|
||||
.entity_has("minecraft:freeze_immune_entity_types", &m.kind.id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let immune = w
|
||||
.items
|
||||
.and_then(|i| i.item_tags.get("minecraft:freeze_immune_wearables"));
|
||||
![&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|item| immune.is_some_and(|t| t.contains(&**item)))
|
||||
}
|
||||
/// `Mob.playAmbientSound` on demand: a spawn egg or a mob bucket just placed `m`.
|
||||
pub fn ambient_sound(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
play_ambient(m, w, events);
|
||||
@@ -895,7 +997,7 @@ fn sun_burn_tick(m: &mut Mob, w: &MobWorld) -> bool {
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let br = w.brightness(x, y, z);
|
||||
let wet = m.fluids.in_water || w.raining && w.can_see_sky(x, y, z);
|
||||
let wet = m.fluids.in_water || in_rain(m, w);
|
||||
br > 0.5 && m.random.next_float() * 30.0 < (br - 0.4) * 2.0 && !wet && w.can_see_sky(x, y, z)
|
||||
}
|
||||
/// Mob.serverAiStep
|
||||
@@ -1682,8 +1784,20 @@ pub fn step_sound(
|
||||
S::Husk => quiet("entity.husk.step"),
|
||||
S::Drowned => quiet("entity.drowned.step"),
|
||||
S::ZombieVillager => quiet("entity.zombie_villager.step"),
|
||||
S::CamelHusk => Some(("entity.camel_husk.step", 0.4, 1.0)),
|
||||
S::Camel => Some(("entity.camel.step", 1.0, 1.0)),
|
||||
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
|
||||
// powder have their own step.
|
||||
S::CamelHusk | S::Camel => {
|
||||
let sand = w.has_tag(
|
||||
"minecraft:camel_sand_step_sound_blocks",
|
||||
&w.facts.get(state).name,
|
||||
);
|
||||
Some(match (m.species(), sand) {
|
||||
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
|
||||
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
|
||||
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
|
||||
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
|
||||
})
|
||||
}
|
||||
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
|
||||
// CopperGolem.playStepSound: its oxidation level's step.
|
||||
S::CopperGolem => Some((super::copper_golem::sound(m, "step"), 1.0, 1.0)),
|
||||
@@ -1762,3 +1876,41 @@ mod despawn_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod frog_tests {
|
||||
use super::*;
|
||||
use crate::mobs::MobTypes;
|
||||
use crate::mobs::control::MoveKind;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `Frog()`: `new SmoothSwimmingMoveControl<>(this, 85, 10, 0.02F, 0.1F, true)`.
|
||||
/// The frog's movement speed is 1.0, so the controller's 0.1 outside-water
|
||||
/// speed is what keeps a walking frog at vanilla's pace. The plain ground
|
||||
/// controller walks it ten times too fast, which is the jitter, the
|
||||
/// teleporting and the flying of #2213.
|
||||
#[test]
|
||||
fn a_frog_walks_at_the_vanilla_speed() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-types.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = MobTypes::load(&root).unwrap();
|
||||
let kind = Arc::new(data.types.get("minecraft:frog").unwrap().clone());
|
||||
assert_eq!(kind.speed, 1.0, "the frog's movement speed attribute");
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
register(&mut m);
|
||||
assert_eq!(
|
||||
m.ai.mov.kind,
|
||||
MoveKind::SmoothSwimming {
|
||||
max_turn_x: 85.0,
|
||||
max_turn_y: 10.0,
|
||||
in_water: 0.02,
|
||||
outside: 0.1,
|
||||
gravity: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
}
|
||||
S::Dolphin => {
|
||||
// Dolphin.tick: moisture out of water, hop on land.
|
||||
if m.fluids.in_water || w.raining {
|
||||
if m.fluids.in_water || super::ai::in_rain(m, w) {
|
||||
m.extra.counter = 2400;
|
||||
} else {
|
||||
m.extra.counter -= 1;
|
||||
|
||||
@@ -323,12 +323,19 @@ pub fn sting(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
match who {
|
||||
Who::Player => {
|
||||
cx.events.push(MobEvent::AttackPlayer(amount, from));
|
||||
let seconds = if cx.w.difficulty.hard { 18 } else { 10 };
|
||||
cx.events.push(MobEvent::EffectPlayer {
|
||||
effect: "minecraft:poison",
|
||||
ticks: seconds * 20,
|
||||
amplifier: 0,
|
||||
});
|
||||
// Poison only on normal (10 s) and hard (18 s).
|
||||
let seconds = match cx.w.difficulty.id {
|
||||
2 => 10,
|
||||
3 => 18,
|
||||
_ => 0,
|
||||
};
|
||||
if seconds > 0 {
|
||||
cx.events.push(MobEvent::EffectPlayer {
|
||||
effect: "minecraft:poison",
|
||||
ticks: seconds * 20,
|
||||
amplifier: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
|
||||
target: id,
|
||||
@@ -421,10 +428,10 @@ pub fn drop_off_nectar(m: &mut Mob) {
|
||||
set_has_nectar(m, false);
|
||||
st(m).crops_grown = 0;
|
||||
}
|
||||
/// `Bee.setStayOutOfHiveCountdown`, which in 26.3 only resets the time
|
||||
/// without nectar.
|
||||
/// `Bee.setStayOutOfHiveCountdown`: the bee will not enter a hive for 400
|
||||
/// ticks (the hive passes that when a sedated hive lets its bees out).
|
||||
pub fn set_stay_out_of_hive_countdown(m: &mut Mob) {
|
||||
st(m).ticks_without_nectar = 0;
|
||||
st(m).stay_out_of_hive = 400;
|
||||
}
|
||||
fn drop_hive(m: &mut Mob) {
|
||||
let b = st(m);
|
||||
|
||||
@@ -980,9 +980,10 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
|
||||
Species::Parrot => {
|
||||
let flap = m.anim.flap_o + (m.anim.flap - m.anim.flap_o) * s.partial;
|
||||
let speed = m.anim.flap_speed_o + (m.anim.flap_speed - m.anim.flap_speed_o) * s.partial;
|
||||
// `Parrot.isFlying` is `!onGround`, in water too.
|
||||
let pose = if m.extra.sitting {
|
||||
ParrotPose::Sitting
|
||||
} else if !m.on_ground && !m.fluids.in_water {
|
||||
} else if !m.on_ground {
|
||||
ParrotPose::Flying
|
||||
} else {
|
||||
ParrotPose::Standing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Mob controls: `MoveControl` (with the flying and smooth-swimming
|
||||
//! variants), `LookControl`, `JumpControl` and `BodyRotationControl`.
|
||||
use super::{Mob, MobWorld, living::wrap};
|
||||
use super::{Mob, MobWorld, Species, living::wrap};
|
||||
use horizon_core::geometry::trig;
|
||||
|
||||
/// `2.5000003E-7F` widened: the squared distance controls treat as arrived.
|
||||
@@ -116,8 +116,16 @@ pub struct BodyRotation {
|
||||
last_stable_head: f32,
|
||||
}
|
||||
impl Mob {
|
||||
/// `Mob.getMaxHeadXRot`, with `Wolf`'s 20 degrees while sitting.
|
||||
pub fn max_head_x_rot(&self) -> f32 {
|
||||
if self.species() == Species::Wolf && self.extra.sitting {
|
||||
20.0
|
||||
} else {
|
||||
self.kind.max_head_x_rot
|
||||
}
|
||||
}
|
||||
pub fn look_at(&mut self, at: [f64; 3]) {
|
||||
let (y, x) = (self.kind.head_rot_speed, self.kind.max_head_x_rot);
|
||||
let (y, x) = (self.kind.head_rot_speed, self.max_head_x_rot());
|
||||
self.ai.look.set_look_at(at, y, x);
|
||||
}
|
||||
pub fn look_at_speed(&mut self, at: [f64; 3], y_speed: f32, x_max: f32) {
|
||||
|
||||
@@ -403,6 +403,15 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop
|
||||
out.push(Drop::Item(stack));
|
||||
clear_slot(m, "mainhand");
|
||||
}
|
||||
// Allay: what it carries (`ints[1]` of the held item) is its inventory.
|
||||
let allay_load = (s == Species::Allay)
|
||||
.then(|| {
|
||||
m.extra
|
||||
.main_hand
|
||||
.clone()
|
||||
.map(|item| (item, m.extra.ints[1]))
|
||||
})
|
||||
.flatten();
|
||||
let drops_loot = d.mob_drops && (is_monster(s) || !m.is_baby());
|
||||
if drops_loot {
|
||||
let table = format!(
|
||||
@@ -459,6 +468,19 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop
|
||||
out.push(Drop::Item(loot_stack("minecraft:nether_star", 1)));
|
||||
}
|
||||
}
|
||||
// Allay.dropEquipment: its inventory, then the main-hand item unless it
|
||||
// prevents equipment drops.
|
||||
if let Some((item, carried)) = allay_load {
|
||||
if carried > 0 {
|
||||
out.push(Drop::Item(loot_stack(&item, carried)));
|
||||
}
|
||||
if let Some(stack) = slot_item(m, "mainhand")
|
||||
&& !d.loot.prevents_equipment_drop(&stack)
|
||||
{
|
||||
out.push(Drop::Item(stack));
|
||||
clear_slot(m, "mainhand");
|
||||
}
|
||||
}
|
||||
// Piglin.dropCustomDeathLoot: everything in its inventory.
|
||||
if s == Species::Piglin {
|
||||
for stack in m.extra.inventory.drain(..) {
|
||||
|
||||
@@ -184,15 +184,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.fluids.in_water
|
||||
|| w.raining
|
||||
&& w.can_see_sky(
|
||||
m.position.x.floor() as i32,
|
||||
m.eye_y().floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
))
|
||||
&& m.alive()
|
||||
{
|
||||
if (m.fluids.in_water || super::ai::in_rain(m, w)) && m.alive() {
|
||||
// Hurt by water: teleport away.
|
||||
m.extra.counter2 = m.extra.counter2.max(1);
|
||||
}
|
||||
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user