mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge origin/main into refactor/entity-walk-lightning
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
This commit is contained in:
commit
0c8f97e4a3
44 files changed
+1679
-420
No files matched your search
@@ -135,6 +135,9 @@ pub struct Extras {
|
||||
pub splitscreen_three: u8,
|
||||
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
|
||||
pub splitscreen_names: bool,
|
||||
/// The wheel over a cycle button in the settings changes it (`CycleButton.mouseScrolled`,
|
||||
/// on by default as vanilla); off, the wheel only scrolls the list.
|
||||
pub wheel_cycles_settings: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -177,6 +180,7 @@ impl Default for Extras {
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
splitscreen_names: true,
|
||||
wheel_cycles_settings: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +265,7 @@ impl Extras {
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
|
||||
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -268,7 +273,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -312,6 +317,7 @@ impl Extras {
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
bool_text(self.splitscreen_names),
|
||||
bool_text(self.wheel_cycles_settings),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -456,6 +462,7 @@ pub enum Item {
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
WheelCyclesSettings,
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -480,6 +487,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(AutoReconnect),
|
||||
Err("horizon.extras.header.sound"),
|
||||
Ok(UiSoundReserve),
|
||||
Err("horizon.extras.header.menus"),
|
||||
Ok(WheelCyclesSettings),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
@@ -503,6 +512,7 @@ impl Item {
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
@@ -525,6 +535,7 @@ impl Item {
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
@@ -556,6 +567,7 @@ impl Item {
|
||||
Item::ReproKeys => "Recording shortcuts",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -568,7 +580,7 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
| ChatAnimation | WheelCyclesSettings => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -623,6 +635,9 @@ impl Item {
|
||||
Item::ReproKeys => {
|
||||
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
|
||||
}
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -638,7 +653,8 @@ impl Item {
|
||||
| Item::AutoReconnect
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen => Some(Cost::None),
|
||||
| Item::Splitscreen
|
||||
| Item::WheelCyclesSettings => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -661,6 +677,7 @@ impl Item {
|
||||
Item::ReproKeys => e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -694,6 +711,7 @@ impl Item {
|
||||
Item::ReproKeys => e.repro_keys = !e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -717,7 +735,7 @@ mod tests {
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles);
|
||||
assert!(d.discord_presence);
|
||||
assert!(d.discord_presence && d.wheel_cycles_settings);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -758,6 +776,8 @@ mod tests {
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
Item::WheelCyclesSettings.cycle(&mut e, false);
|
||||
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -1363,6 +1363,11 @@ impl GraphicsUi {
|
||||
framebuffer: [i32; 2],
|
||||
amount: f32,
|
||||
) -> Vec<Action> {
|
||||
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
|
||||
if !s.extras.wheel_cycles_settings {
|
||||
self.scroll(level, s, h, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
// Horizon improvement: focused slider/cycle intercepts wheel first.
|
||||
if amount != 0.0
|
||||
&& self.can_change_value
|
||||
@@ -2253,6 +2258,15 @@ mod tests {
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
|
||||
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
|
||||
// With the "Scroll wheel changes settings" extra off, it scrolls instead.
|
||||
s.extras.wheel_cycles_settings = false;
|
||||
let scroll_before = ui.scroll.amount;
|
||||
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
|
||||
assert!(ui.scroll.amount > scroll_before);
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
ui.scroll.amount = scroll_before;
|
||||
|
||||
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
|
||||
s.horizon.chunk_updates = 16;
|
||||
|
||||
@@ -82,6 +82,7 @@ pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
pub mod section_mask;
|
||||
pub mod server_terrain;
|
||||
pub mod settings;
|
||||
pub mod shader_options;
|
||||
pub mod shader_pack_list;
|
||||
|
||||
@@ -1220,10 +1220,9 @@ impl Lod {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
Done::Observed(
|
||||
key,
|
||||
ReducedColumns::new(summarize(&chunk, kinds)),
|
||||
)
|
||||
let columns = summarize(&chunk, kinds);
|
||||
sampler.observed(key, &columns);
|
||||
Done::Observed(key, ReducedColumns::new(columns))
|
||||
}
|
||||
Job::RequestDetail { chunks, center } => {
|
||||
sampler.request_detail(&chunks, center);
|
||||
|
||||
@@ -4233,7 +4233,8 @@ impl OptionsUi {
|
||||
}
|
||||
let plain = self.resource_error.is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
|
||||
if !plain || !s.extras.wheel_cycles_settings {
|
||||
self.scroll_at(s, size, cursor, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -7321,6 +7322,55 @@ mod tests {
|
||||
assert!(events.contains(&Event::Changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_only_scrolls_with_the_extra_off() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Video);
|
||||
let framebuffer = [854, 480];
|
||||
let size = (427.0, 240.0);
|
||||
let (widgets, top, bottom, content) = ui.layout(&s, size.0, size.1);
|
||||
assert!(
|
||||
content > bottom - top,
|
||||
"the video page scrolls at this size"
|
||||
);
|
||||
let (opt, over) = widgets
|
||||
.iter()
|
||||
.find_map(|wd| match wd.kind {
|
||||
WKind::Button {
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
} if !wd.list || (wd.y >= top && wd.y + wd.h <= bottom) => {
|
||||
Some((opt, (wd.x + 5.0, wd.y + 5.0)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("a visible cycle button");
|
||||
|
||||
s.extras.wheel_cycles_settings = false;
|
||||
let before = format!("{s:?}");
|
||||
let start = ui.scroll;
|
||||
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_eq!(format!("{s:?}"), before, "the hovered option stays put");
|
||||
assert!(!events.contains(&Event::Changed));
|
||||
assert!(
|
||||
ui.scroll > start,
|
||||
"the list scrolls under the cursor instead"
|
||||
);
|
||||
// Keyboard focus doesn't take the wheel either.
|
||||
ui.focus = Some(FocusId::Cycle(opt));
|
||||
ui.focus_from_keyboard = true;
|
||||
ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_eq!(format!("{s:?}"), before);
|
||||
|
||||
// On (the default), vanilla's CycleButton.mouseScrolled again.
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
let before = format!("{s:?}");
|
||||
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
|
||||
assert_ne!(format!("{s:?}"), before, "{opt:?} cycles");
|
||||
assert!(events.contains(&Event::Changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_step_slider_matches_set_slider_for_all_stepped_options() {
|
||||
let stepped_opts = [
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
//! Far terrain on servers: every chunk a server sends is kept as its
|
||||
//! far-terrain summary (`lod::Column`s), on disk per server, world and
|
||||
//! dimension, and drawn beyond the server's view distance on this visit and
|
||||
//! the next ones. A server never sends its seed, so its
|
||||
//! distant terrain can't be generated as a local world's is.
|
||||
//!
|
||||
//! The summaries live in Anvil region files (`r.X.Z.mca`, one NBT record a
|
||||
//! chunk), under `server-terrain/<server>/<hashed seed>/<namespace>/<level>/`.
|
||||
use crate::{
|
||||
fast_hash::FxHashMap,
|
||||
lod::{Column, Span, Spans, Surface},
|
||||
streaming::{LodDetail, LodSampler, LodSource},
|
||||
};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
compression::Compression,
|
||||
nbt::{Limits, NamedTag, Tag},
|
||||
region::{ChunkRecord, RegionFile},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// Bumped when the summary layout changes; older records are ignored.
|
||||
const FORMAT: i32 = 1;
|
||||
/// Decoded chunks kept in memory, and open region files.
|
||||
const MAX_CHUNKS: usize = 8192;
|
||||
const MAX_REGIONS: usize = 64;
|
||||
/// The widest cell, in chunks, drawn from the chunks kept under it
|
||||
/// (`Sampler::reduced_column`): its regions all stay open.
|
||||
const MAX_KEPT_SIDE: i32 = 128;
|
||||
|
||||
type Columns = Arc<[Column; 256]>;
|
||||
|
||||
/// The folder of a server's terrain: its address as typed, lowercased, with
|
||||
/// the default port left out, and every character a file name can't hold
|
||||
/// replaced (`play.example.net:25566` → `play.example.net_25566`).
|
||||
pub fn server_folder(address: &str) -> String {
|
||||
let address = address.trim().to_ascii_lowercase();
|
||||
let address = address.strip_suffix(":25565").unwrap_or(&address);
|
||||
let name: String = address
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let name = name.trim_matches('.');
|
||||
if name.is_empty() {
|
||||
"_".into()
|
||||
} else {
|
||||
name.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// `root/<server>/<seed>/<namespace>/<path>` for a level key
|
||||
/// (`minecraft:overworld`) and the login's hashed seed, which keeps
|
||||
/// the worlds behind one address (a proxy's servers, a reset) apart.
|
||||
pub fn level_dir(root: &Path, address: &str, hashed_seed: i64, level: &str) -> PathBuf {
|
||||
root.join(server_folder(address))
|
||||
.join(hashed_seed.to_string())
|
||||
.join(level_path(level))
|
||||
}
|
||||
|
||||
/// `<namespace>/<path>` of a level key.
|
||||
fn level_path(level: &str) -> PathBuf {
|
||||
let (namespace, path) = level.split_once(':').unwrap_or(("minecraft", level));
|
||||
let clean = |s: &str| -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('.')
|
||||
.to_owned()
|
||||
};
|
||||
let mut dir = PathBuf::from(clean(namespace));
|
||||
for part in path.split('/').map(clean).filter(|p| !p.is_empty()) {
|
||||
dir.push(part);
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
struct Region {
|
||||
file: RegionFile,
|
||||
present: [bool; 1024],
|
||||
}
|
||||
|
||||
/// Open region files by region; `None`: the file doesn't exist.
|
||||
type Regions = FxHashMap<(i32, i32), Option<Region>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
regions: Regions,
|
||||
/// `None`: the chunk isn't stored.
|
||||
chunks: FxHashMap<(i32, i32), Option<Columns>>,
|
||||
}
|
||||
|
||||
/// One level's stored terrain, shared by the far-terrain threads.
|
||||
pub struct ServerTerrain {
|
||||
dir: PathBuf,
|
||||
state: Mutex<State>,
|
||||
warned: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl ServerTerrain {
|
||||
pub fn new(dir: PathBuf) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
dir,
|
||||
state: Mutex::default(),
|
||||
warned: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The stored chunk nearest the middle of the `side` × `side` chunks
|
||||
/// from `(x, z)`, found in the region headers without reading any.
|
||||
fn nearest(&self, x: i32, z: i32, side: i32) -> Option<(i32, i32)> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let middle = (x * 2 + side, z * 2 + side);
|
||||
let mut best: Option<(i64, (i32, i32))> = None;
|
||||
for rz in z >> 5..=(z + side - 1) >> 5 {
|
||||
for rx in x >> 5..=(x + side - 1) >> 5 {
|
||||
let present = region(&mut state.regions, &self.dir, (rx, rz))
|
||||
.map_or([false; 1024], |r| r.present);
|
||||
for (i, _) in present.iter().enumerate().filter(|(_, p)| **p) {
|
||||
let (cx, cz) = (rx * 32 + (i % 32) as i32, rz * 32 + (i / 32) as i32);
|
||||
if cx < x || cz < z || cx >= x + side || cz >= z + side {
|
||||
continue;
|
||||
}
|
||||
let (dx, dz) = (
|
||||
i64::from(cx * 2 + 1 - middle.0),
|
||||
i64::from(cz * 2 + 1 - middle.1),
|
||||
);
|
||||
let distance = dx * dx + dz * dz;
|
||||
if best.is_none_or(|(d, _)| distance < d) {
|
||||
best = Some((distance, (cx, cz)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, chunk)| chunk)
|
||||
}
|
||||
|
||||
/// A stored chunk's columns.
|
||||
pub fn chunk(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let key = (x >> 5, z >> 5);
|
||||
let here = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if let Some(known) = state.chunks.get(&(x, z)) {
|
||||
return known.clone();
|
||||
}
|
||||
read(region(&mut state.regions, &self.dir, key), x, z)
|
||||
};
|
||||
// A record of another game version or layout is none.
|
||||
let columns = here.and_then(|record| {
|
||||
record
|
||||
.nbt(Limits::default())
|
||||
.ok()
|
||||
.and_then(|root| decode(&root.tag))
|
||||
});
|
||||
let mut state = self.state.lock().unwrap();
|
||||
// Stored by another thread meanwhile (`write`): that, newer than
|
||||
// what was read here.
|
||||
if let Some(Some(stored)) = state.chunks.get(&(x, z)) {
|
||||
return Some(stored.clone());
|
||||
}
|
||||
remember(&mut state, (x, z), columns.clone());
|
||||
columns
|
||||
}
|
||||
|
||||
/// A chunk couldn't be kept (disk full, a folder it can't write): said
|
||||
/// once a level, not for every chunk after.
|
||||
fn warn_once(&self, error: &std::io::Error) {
|
||||
if !self.warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
log::warn!("Horizon: far terrain can't keep the server's chunks: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a chunk's columns, unless they are what is stored already.
|
||||
pub fn store(&self, key: (i32, i32), columns: &Columns) -> std::io::Result<()> {
|
||||
if self
|
||||
.chunk(key.0, key.1)
|
||||
.is_some_and(|old| *old == **columns)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.write(key, columns)
|
||||
}
|
||||
|
||||
fn write(&self, (x, z): (i32, i32), columns: &Columns) -> std::io::Result<()> {
|
||||
let root = NamedTag {
|
||||
name: "".into(),
|
||||
tag: encode(x, z, columns),
|
||||
};
|
||||
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, now(), Limits::default())?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let key = (x >> 5, z >> 5);
|
||||
if region(&mut state.regions, &self.dir, key).is_none() {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let file = RegionFile::open_writable(region_path(&self.dir, key), true)?;
|
||||
state.regions.insert(key, Some(Region::new(file)));
|
||||
}
|
||||
let region = region(&mut state.regions, &self.dir, key).expect("region just opened");
|
||||
region.file.write(ChunkPos::new(x, z), &record)?;
|
||||
region.present[slot(x, z)] = true;
|
||||
remember(&mut state, (x, z), Some(columns.clone()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn region_path(dir: &Path, (rx, rz): (i32, i32)) -> PathBuf {
|
||||
dir.join(format!("r.{rx}.{rz}.mca"))
|
||||
}
|
||||
|
||||
/// A region of `dir`, opened on first use.
|
||||
fn region<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
|
||||
if !regions.contains_key(&key) {
|
||||
if regions.len() >= MAX_REGIONS {
|
||||
regions.clear();
|
||||
}
|
||||
let file = RegionFile::open_writable(region_path(dir, key), false);
|
||||
regions.insert(key, file.ok().map(Region::new));
|
||||
}
|
||||
regions.get_mut(&key)?.as_mut()
|
||||
}
|
||||
|
||||
fn read(region: Option<&mut Region>, x: i32, z: i32) -> Option<ChunkRecord> {
|
||||
let region = region.filter(|r| r.present[slot(x, z)])?;
|
||||
region.file.read(ChunkPos::new(x, z)).ok().flatten()
|
||||
}
|
||||
|
||||
impl Region {
|
||||
fn new(file: RegionFile) -> Self {
|
||||
let mut present = [false; 1024];
|
||||
for (pos, _) in file.chunks() {
|
||||
present[slot(pos.x, pos.z)] = true;
|
||||
}
|
||||
Self { file, present }
|
||||
}
|
||||
}
|
||||
|
||||
fn slot(x: i32, z: i32) -> usize {
|
||||
(x & 31) as usize + (z & 31) as usize * 32
|
||||
}
|
||||
|
||||
fn remember(state: &mut State, key: (i32, i32), columns: Option<Columns>) {
|
||||
if state.chunks.len() >= MAX_CHUNKS {
|
||||
state.chunks.clear();
|
||||
}
|
||||
state.chunks.insert(key, columns);
|
||||
}
|
||||
|
||||
fn now() -> u32 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as u32)
|
||||
}
|
||||
|
||||
/// The columns as one int array: per column a surface (`0`, or `1`, height,
|
||||
/// state, water flag, water height, water state, biome), the span count, and
|
||||
/// three ints a span (min and max, state, biome and light).
|
||||
fn encode(x: i32, z: i32, columns: &[Column; 256]) -> Tag {
|
||||
let mut ints = Vec::with_capacity(256 * 8);
|
||||
for column in columns {
|
||||
match column.surface {
|
||||
None => ints.push(0),
|
||||
Some(s) => {
|
||||
let (water, water_height, water_state) = s
|
||||
.water
|
||||
.map_or((0, 0, 0), |(h, state)| (1, h, state.0 as i32));
|
||||
ints.extend([
|
||||
1,
|
||||
s.height,
|
||||
s.state.0 as i32,
|
||||
water,
|
||||
water_height,
|
||||
water_state,
|
||||
i32::from(s.biome),
|
||||
]);
|
||||
}
|
||||
}
|
||||
ints.push(column.spans.len() as i32);
|
||||
for span in &column.spans {
|
||||
ints.extend([
|
||||
i32::from(span.min as u16) << 16 | i32::from(span.max as u16),
|
||||
span.state.0 as i32,
|
||||
i32::from(span.biome) << 16 | i32::from(span.light),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("HorizonFarTerrain", Tag::Int(FORMAT)),
|
||||
("xPos", Tag::Int(x)),
|
||||
("zPos", Tag::Int(z)),
|
||||
("columns", Tag::IntArray(ints)),
|
||||
])
|
||||
}
|
||||
|
||||
/// `encode`'s columns, or `None` for a record of another game version or
|
||||
/// layout (their block states and biomes are numbered differently).
|
||||
fn decode(tag: &Tag) -> Option<Columns> {
|
||||
if tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION))
|
||||
|| tag.get("HorizonFarTerrain") != Some(&Tag::Int(FORMAT))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let Some(Tag::IntArray(ints)) = tag.get("columns") else {
|
||||
return None;
|
||||
};
|
||||
let mut ints = ints.iter().copied();
|
||||
let mut next = || ints.next();
|
||||
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
|
||||
for column in &mut columns {
|
||||
column.surface = match next()? {
|
||||
0 => None,
|
||||
1 => {
|
||||
let height = next()?;
|
||||
let state = BlockStateId(next()? as u32);
|
||||
let water = next()?;
|
||||
let water_height = next()?;
|
||||
let water_state = BlockStateId(next()? as u32);
|
||||
let biome = next()? as u16;
|
||||
Some(Surface {
|
||||
height,
|
||||
state,
|
||||
water: (water != 0).then_some((water_height, water_state)),
|
||||
biome,
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let count = usize::try_from(next()?).ok().filter(|&n| n <= 4096)?;
|
||||
column.spans = Spans::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let range = next()?;
|
||||
let state = BlockStateId(next()? as u32);
|
||||
let material = next()?;
|
||||
column.spans.push(Span {
|
||||
min: (range >> 16) as i16,
|
||||
max: range as i16,
|
||||
state,
|
||||
biome: (material >> 16) as u16,
|
||||
light: material as u16,
|
||||
});
|
||||
}
|
||||
}
|
||||
next().is_none().then(|| Arc::new(columns))
|
||||
}
|
||||
|
||||
impl LodSource for ServerTerrain {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
|
||||
Box::new(Sampler {
|
||||
terrain: self,
|
||||
details: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Sampler {
|
||||
terrain: Arc<ServerTerrain>,
|
||||
details: Vec<LodDetail>,
|
||||
}
|
||||
|
||||
impl LodSampler for Sampler {
|
||||
fn fork(&self) -> Option<Box<dyn LodSampler>> {
|
||||
Some(Box::new(Self {
|
||||
terrain: self.terrain.clone(),
|
||||
details: Vec::new(),
|
||||
}))
|
||||
}
|
||||
/// Stored chunks come back whole; the others are known empty.
|
||||
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
|
||||
for &(x, z) in chunks {
|
||||
let columns = self
|
||||
.terrain
|
||||
.chunk(x, z)
|
||||
.unwrap_or_else(|| Arc::new(std::array::from_fn(|_| Column::default())));
|
||||
self.details.push(((x, z), columns));
|
||||
}
|
||||
}
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
std::mem::take(&mut self.details)
|
||||
}
|
||||
fn observed(&mut self, key: (i32, i32), columns: &Columns) {
|
||||
if let Err(e) = self.terrain.store(key, columns) {
|
||||
self.terrain.warn_once(&e);
|
||||
}
|
||||
}
|
||||
fn sample(&mut self, x: i32, z: i32) -> Option<Surface> {
|
||||
self.terrain.chunk(x >> 4, z >> 4)?[((z & 15) * 16 + (x & 15)) as usize].surface
|
||||
}
|
||||
fn column(&mut self, x: i32, z: i32) -> Column {
|
||||
self.terrain
|
||||
.chunk(x >> 4, z >> 4)
|
||||
.map(|c| c[((z & 15) * 16 + (x & 15)) as usize].clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// A cell of `width` blocks from one stored chunk: its own area up to a
|
||||
/// chunk, else the stored chunk nearest the cell's middle (the server
|
||||
/// sent only the chunks a player came near).
|
||||
fn reduced_column(&mut self, x: i32, z: i32, width: i32) -> Column {
|
||||
if width <= 16 {
|
||||
let Some(chunk) = self.terrain.chunk(x >> 4, z >> 4) else {
|
||||
return Column::default();
|
||||
};
|
||||
let width = width.max(1) as usize;
|
||||
let lx = (x & 15) as usize & !(width - 1);
|
||||
let lz = (z & 15) as usize & !(width - 1);
|
||||
return Column::reduced(&chunk, lx, lz, width);
|
||||
}
|
||||
// Searching wider cells would open thousands of region files.
|
||||
if width >> 4 > MAX_KEPT_SIDE {
|
||||
return Column::default();
|
||||
}
|
||||
self.terrain
|
||||
.nearest(x >> 4, z >> 4, width >> 4)
|
||||
.and_then(|(cx, cz)| self.terrain.chunk(cx, cz))
|
||||
.map_or_else(Column::default, |chunk| Column::reduced(&chunk, 0, 0, 16))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn columns() -> Columns {
|
||||
Arc::new(std::array::from_fn(|i| {
|
||||
if i % 3 == 0 {
|
||||
return Column::default();
|
||||
}
|
||||
Column {
|
||||
surface: Some(Surface {
|
||||
height: 63 + i as i32 % 7,
|
||||
state: BlockStateId(9 + i as u32),
|
||||
water: (i % 2 == 0).then_some((62, BlockStateId(86))),
|
||||
biome: (i % 5) as u16,
|
||||
}),
|
||||
spans: vec![
|
||||
Span {
|
||||
min: -64,
|
||||
max: 40,
|
||||
state: BlockStateId(1),
|
||||
biome: 3,
|
||||
light: 0,
|
||||
},
|
||||
Span {
|
||||
min: 40,
|
||||
max: 63 + i as i16 % 7,
|
||||
state: BlockStateId(9),
|
||||
biome: 7,
|
||||
light: 0xF0,
|
||||
},
|
||||
]
|
||||
.into(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_survive_their_encoding() {
|
||||
let columns = columns();
|
||||
assert_eq!(decode(&encode(3, -4, &columns)), Some(columns));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_of_another_version_are_ignored() {
|
||||
let mut tag = encode(0, 0, &columns());
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.insert("DataVersion".into(), Tag::Int(DATA_VERSION - 1));
|
||||
}
|
||||
assert_eq!(decode(&tag), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_folders_are_plain_names() {
|
||||
assert_eq!(server_folder("Play.Example.net"), "play.example.net");
|
||||
assert_eq!(server_folder("play.example.net:25565"), "play.example.net");
|
||||
assert_eq!(
|
||||
server_folder("play.example.net:25566"),
|
||||
"play.example.net_25566"
|
||||
);
|
||||
assert_eq!(server_folder("[::1]:25570"), "___1__25570");
|
||||
assert_eq!(server_folder("../.."), "_");
|
||||
assert_eq!(
|
||||
level_dir(Path::new("/c"), "a", -7, "minecraft:the_nether"),
|
||||
Path::new("/c/a/-7/minecraft/the_nether")
|
||||
);
|
||||
assert_eq!(
|
||||
level_dir(Path::new("/c"), "a", 0, "mod:../../x"),
|
||||
Path::new("/c/a/0/mod/x")
|
||||
);
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_chunks_come_back_after_a_restart() {
|
||||
let dir = scratch("server-terrain");
|
||||
let columns = columns();
|
||||
{
|
||||
let terrain = ServerTerrain::new(dir.clone());
|
||||
assert_eq!(terrain.chunk(-33, 5), None);
|
||||
terrain.store((-33, 5), &columns).unwrap();
|
||||
}
|
||||
let terrain = ServerTerrain::new(dir.clone());
|
||||
assert_eq!(terrain.chunk(-33, 5), Some(columns.clone()));
|
||||
assert_eq!(terrain.nearest(-64, 0, 64), Some((-33, 5)));
|
||||
assert_eq!(terrain.nearest(-32, 0, 64), None);
|
||||
let mut sampler = terrain.clone().sampler();
|
||||
assert_eq!(sampler.sample(-33 * 16 + 1, 5 * 16), columns[1].surface);
|
||||
assert_eq!(sampler.column(0, 0), Column::default());
|
||||
// A far cell finds the stored chunk anywhere in it.
|
||||
assert_ne!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,8 @@ pub trait LodSampler: Send {
|
||||
fn drain_details(&mut self) -> Vec<LodDetail> {
|
||||
Vec::new()
|
||||
}
|
||||
/// A loaded chunk's summary, made on the far-terrain worker.
|
||||
fn observed(&mut self, _key: (i32, i32), _columns: &Arc<[crate::lod::Column; 256]>) {}
|
||||
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
|
||||
/// approximate wide cells.
|
||||
fn set_approximate(&mut self, _approximate: bool) {}
|
||||
|
||||
@@ -225,6 +225,7 @@
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.multiplayer": "Multiplayer",
|
||||
"horizon.extras.header.sound": "Sound",
|
||||
"horizon.extras.header.menus": "Menus",
|
||||
"horizon.extras.fullbright": "Fullbright",
|
||||
"horizon.extras.dynamic_lights": "Dynamic lights",
|
||||
"horizon.extras.own_name_tag": "Own name tag",
|
||||
@@ -241,6 +242,7 @@
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
"horizon.extras.wheel_cycles_settings": "Scroll wheel changes settings",
|
||||
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
|
||||
@@ -309,6 +311,7 @@
|
||||
"horizon.osk.close": "Close",
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.wheel_cycles_settings.tooltip": "The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
|
||||
|
||||
@@ -331,6 +331,9 @@
|
||||
"horizon.extras.header.chat": "Чат",
|
||||
"horizon.extras.header.particles": "Частинки",
|
||||
"horizon.extras.header.sound": "Звук",
|
||||
"horizon.extras.header.menus": "Меню",
|
||||
"horizon.extras.wheel_cycles_settings": "Коліщатко змінює налаштування",
|
||||
"horizon.extras.wheel_cycles_settings.tooltip": "Коліщатко миші над кнопкою в налаштуваннях змінює її значення, як у ванільній грі. Вимкнено — коліщатко лише гортає список.",
|
||||
"horizon.extras.hide_block_particles": "Без частинок блоків",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.",
|
||||
"horizon.extras.splitscreen": "Розділений екран",
|
||||
|
||||
@@ -17,10 +17,11 @@ pub use horizon_client_render::{
|
||||
menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit,
|
||||
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
|
||||
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
|
||||
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
|
||||
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
|
||||
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
|
||||
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
|
||||
world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
|
||||
@@ -21,18 +21,44 @@ use horizon_world::{
|
||||
|
||||
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
|
||||
const ITEM_GRAVITY: f64 = 0.04;
|
||||
/// Non-living slime `bounceRestitution` (`1.0 * 0.8`) from
|
||||
/// Non-living slime `bounceRestitution` factor (`0.8f`) from
|
||||
/// `Entity.restituteMovementAfterCollisions`.
|
||||
const ITEM_SLIME_BOUNCE: f64 = 0.8;
|
||||
const ITEM_SLIME_BOUNCE: f32 = 0.8;
|
||||
|
||||
/// After a vertical collision: slime bounces a dropped item by 0.8, matching
|
||||
/// non-living `Entity.restituteMovementAfterCollisions`. Anything else zeros Y.
|
||||
fn item_vertical_collision_velocity(wanted_y: f64, slime: bool) -> f64 {
|
||||
if slime && wanted_y < 0.0 && -wanted_y > ITEM_GRAVITY {
|
||||
-wanted_y * ITEM_SLIME_BOUNCE
|
||||
} else {
|
||||
0.0
|
||||
/// `Entity.restituteMovementAfterCollisions` for the vertical axis of a
|
||||
/// non-living entity: `current_y` is its velocity before the move, `moved_y`
|
||||
/// how far the move got. Landing on slime (`below`) bounces by the block's
|
||||
/// restitution `1.0f * 0.8f`, with the gravity and air drag the part of the tick
|
||||
/// after the hit would have added back; any other collision zeroes Y.
|
||||
pub fn vertical_collision_velocity(
|
||||
current_y: f64,
|
||||
moved_y: f64,
|
||||
gravity: f64,
|
||||
below: bool,
|
||||
slime: bool,
|
||||
) -> f64 {
|
||||
if !(below && slime && -current_y > gravity) {
|
||||
return 0.0;
|
||||
}
|
||||
let restitution = f64::from(1.0_f32 * ITEM_SLIME_BOUNCE);
|
||||
let portion = moved_y / current_y;
|
||||
let drag = 1.0 + portion * (f64::from(0.98_f32) - 1.0);
|
||||
(portion * gravity - current_y) * drag * restitution
|
||||
}
|
||||
|
||||
/// `Entity.move`: the position only changes when the collided step is longer
|
||||
/// than `sqrt(1e-7)` or nothing was cut off; a tiny step against a wall or floor
|
||||
/// is dropped (its collision effects still run).
|
||||
pub fn move_applies(wanted: Vec3, moved: Vec3) -> bool {
|
||||
let moved_sq = moved.length_squared();
|
||||
moved_sq > 1.0e-7 || wanted.length_squared() - moved_sq < 1.0e-7
|
||||
}
|
||||
|
||||
/// `SlimeBlock.stepOn`: a slow walk or slide on slime drags the horizontal
|
||||
/// speed (`0.4 + |dy| * 0.2`); a fast vertical speed leaves it alone.
|
||||
pub fn slime_step_on_scale(velocity_y: f64) -> Option<f64> {
|
||||
let abs_delta_y = velocity_y.abs();
|
||||
(abs_delta_y < 0.1).then_some(0.4 + abs_delta_y * 0.2)
|
||||
}
|
||||
|
||||
fn slime_support_name(block: &str, moving_moved: Option<&str>) -> bool {
|
||||
@@ -278,11 +304,13 @@ impl ItemEntity {
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
if move_applies(wanted, moved) {
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
}
|
||||
if !no_physics {
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
}
|
||||
@@ -304,7 +332,13 @@ impl ItemEntity {
|
||||
// (or a moving piston carrying one) bounces a dropped item;
|
||||
// MovingPistonBlock itself has no bounceRestitution.
|
||||
let slime = item_support_is_slime(world, facts, self.position);
|
||||
self.velocity.y = item_vertical_collision_velocity(wanted.y, slime);
|
||||
self.velocity.y = vertical_collision_velocity(
|
||||
wanted.y,
|
||||
moved.y,
|
||||
ITEM_GRAVITY,
|
||||
wanted.y < 0.0,
|
||||
slime,
|
||||
);
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
|
||||
// (before air drag), matching ItemEntity.tick.
|
||||
@@ -318,6 +352,11 @@ impl ItemEntity {
|
||||
self.bounds(),
|
||||
&mut self.velocity,
|
||||
);
|
||||
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn, with the
|
||||
// vertical speed the bounce just left (before the air drag).
|
||||
if !no_physics {
|
||||
self.slime_step_on(world, facts);
|
||||
}
|
||||
let mut friction = 0.98;
|
||||
if self.on_ground {
|
||||
let below = world.block(
|
||||
@@ -337,6 +376,9 @@ impl ItemEntity {
|
||||
}
|
||||
} else {
|
||||
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
|
||||
if !no_physics {
|
||||
self.slime_step_on(world, facts);
|
||||
}
|
||||
crate::motion::bubble_column_push(
|
||||
world,
|
||||
registry,
|
||||
@@ -411,6 +453,26 @@ impl ItemEntity {
|
||||
}
|
||||
stuck
|
||||
}
|
||||
/// `SlimeBlock.stepOn` for an item on the ground: slow vertical speed
|
||||
/// drags the horizontal speed, on idle ticks too.
|
||||
fn slime_step_on(&mut self, world: &World, facts: &BlockFacts) {
|
||||
if !self.on_ground {
|
||||
return;
|
||||
}
|
||||
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
|
||||
0.0,
|
||||
-f64::from(0.2_f32),
|
||||
0.0,
|
||||
)));
|
||||
if &*facts.get(world.block(p.x, p.y, p.z)).name != "minecraft:slime_block" {
|
||||
return;
|
||||
}
|
||||
if let Some(scale) = slime_step_on_scale(self.velocity.y) {
|
||||
self.velocity.x *= scale;
|
||||
self.velocity.z *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.causeBounce` for a dropped item. Vanilla then
|
||||
/// runs `Entity.move(MoverType.PISTON)`, which leaves the ground on an
|
||||
/// upward push; without that, `ItemEntity.tick` skips movement 3 of 4 ticks
|
||||
@@ -2116,22 +2178,38 @@ mod slime_bounce_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn landing_on_slime_bounces_a_dropped_item() {
|
||||
assert_eq!(item_vertical_collision_velocity(-1.0, true), 0.8);
|
||||
assert_eq!(item_vertical_collision_velocity(-0.5, true), 0.4);
|
||||
fn landing_on_slime_bounces_a_dropped_item_like_vanilla() {
|
||||
// Measured on a vanilla 26.3 server: an item falling at -0.6097 that
|
||||
// is stopped 0.4865 down leaves with 0.4950 / 0.98 after the bounce.
|
||||
let y = vertical_collision_velocity(-0.6097, -0.4865, ITEM_GRAVITY, true, true);
|
||||
assert!((y - 0.4950 / 0.98).abs() < 1e-3, "{y}");
|
||||
// A tiny landing: -0.0792 stopped after 0.06 gives 0.0862 (vanilla).
|
||||
let y = vertical_collision_velocity(-0.0792, -0.06, ITEM_GRAVITY, true, true);
|
||||
assert!((y - 0.0862).abs() < 1e-3, "{y}");
|
||||
assert_eq!(
|
||||
item_vertical_collision_velocity(-0.04, true),
|
||||
vertical_collision_velocity(-0.04, -0.01, ITEM_GRAVITY, true, true),
|
||||
0.0,
|
||||
"gravity alone is not a bounce"
|
||||
);
|
||||
assert_eq!(item_vertical_collision_velocity(-0.041, true), 0.041 * 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_on_stone_or_hitting_a_ceiling_zeros_y() {
|
||||
assert_eq!(item_vertical_collision_velocity(-1.0, false), 0.0);
|
||||
assert_eq!(item_vertical_collision_velocity(1.0, true), 0.0);
|
||||
assert_eq!(item_vertical_collision_velocity(0.96, true), 0.0);
|
||||
assert_eq!(
|
||||
vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, false),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
vertical_collision_velocity(1.0, 0.5, ITEM_GRAVITY, false, true),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slime_step_on_drags_slow_vertical_speed_only() {
|
||||
let scale = slime_step_on_scale(0.0862).unwrap();
|
||||
assert!((scale - 0.41724).abs() < 1e-5);
|
||||
assert_eq!(slime_step_on_scale(0.1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2204,9 +2282,8 @@ mod slime_bounce_tests {
|
||||
fn a_slime_landing_stays_upward_after_item_friction() {
|
||||
// ItemEntity then multiplies Y by 0.98 and applies -0.5 only if still
|
||||
// negative on the ground. A slime bounce must remain positive.
|
||||
let bounced = item_vertical_collision_velocity(-1.0, true) * 0.98;
|
||||
let bounced = vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * 0.98;
|
||||
assert!(bounced > 0.0);
|
||||
assert_eq!(bounced, 0.8 * 0.98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2298,6 +2298,8 @@ struct Game<'a> {
|
||||
lod: Option<horizon_client::lod::Lod>,
|
||||
lod_source_attempted: bool,
|
||||
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// The server level far terrain keeps the chunks of.
|
||||
lod_level: Option<String>,
|
||||
/// Beacon beam sections kept after their chunk unloads, so far terrain
|
||||
/// LOD still draws their beams (`beacon_geometry` refreshes them).
|
||||
far_beacons: HashMap<(i32, i32, i32), Vec<(u32, i32)>>,
|
||||
@@ -16607,20 +16609,28 @@ impl Game<'_> {
|
||||
let enabled = self.far_terrain_choice().0 > 0
|
||||
&& self.renderer.is_some()
|
||||
&& self.player.is_some()
|
||||
&& self.title.is_none()
|
||||
&& (self.remote.is_none()
|
||||
|| self.integrated.is_some()
|
||||
|| self.server_far_terrain().is_some());
|
||||
&& self.title.is_none();
|
||||
if !enabled {
|
||||
if self.lod.is_some() || self.lod_source_attempted {
|
||||
self.clear_lod();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Another level: its own kept chunks. (Another world at the same
|
||||
// address, a proxy's next server, comes through a new login; a seed
|
||||
// changed at a respawn of the same level is a plugin hiding it.)
|
||||
if self
|
||||
.lod_level
|
||||
.as_ref()
|
||||
.is_some_and(|level| self.remote.as_ref().is_some_and(|r| r.level != *level))
|
||||
{
|
||||
self.clear_lod();
|
||||
}
|
||||
if self.lod_source_attempted {
|
||||
return;
|
||||
}
|
||||
self.lod_source_attempted = true;
|
||||
self.lod_level = None;
|
||||
let dim = self.dim;
|
||||
let source = match (&self.integrated, self.server_far_terrain(), &self.remote) {
|
||||
(Some(server), ..) => server.call(move |game| game.lod_source_in(dim)).flatten(),
|
||||
@@ -16631,6 +16641,31 @@ impl Game<'_> {
|
||||
remote.session.far_terrain(offer, biomes, states);
|
||||
Some(source)
|
||||
}
|
||||
// A friend's world (a new code at every share) or a splitscreen
|
||||
// guest's (the player's own world, its other guests writing the
|
||||
// same folder): nothing to keep.
|
||||
(None, None, Some(remote))
|
||||
if self.options.split_guest
|
||||
|| remote
|
||||
.session
|
||||
.address
|
||||
.starts_with(horizon_client::net::FRIEND) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
// No offer from a server: draw what it sent, on this visit and before.
|
||||
(None, None, Some(remote)) => std::env::current_dir().ok().map(|home| {
|
||||
use horizon_client::server_terrain as terrain;
|
||||
let address = &remote.session.address;
|
||||
let dir = terrain::level_dir(
|
||||
&home.join("server-terrain"),
|
||||
address,
|
||||
remote.hashed_seed,
|
||||
&remote.level,
|
||||
);
|
||||
self.lod_level = Some(remote.level.clone());
|
||||
terrain::ServerTerrain::new(dir) as _
|
||||
}),
|
||||
_ => Some(self.streaming.lod_source()),
|
||||
};
|
||||
if let Some(source) = source {
|
||||
@@ -18612,6 +18647,7 @@ impl<'a> Game<'a> {
|
||||
lod_source_attempted: false,
|
||||
far_beacons: HashMap::new(),
|
||||
lod_observe: Default::default(),
|
||||
lod_level: None,
|
||||
lod_covered_at: None,
|
||||
view_arrival: Default::default(),
|
||||
options,
|
||||
|
||||
@@ -45,6 +45,8 @@ pub(super) struct Boat {
|
||||
pub delta_rotation: f32,
|
||||
status: Status,
|
||||
old_status: Status,
|
||||
/// `AbstractBoat.oldStatus` is null until the first tick has set a status.
|
||||
status_known: bool,
|
||||
water_level: f64,
|
||||
land_friction: f32,
|
||||
out_of_control: f32,
|
||||
@@ -126,6 +128,7 @@ impl Boat {
|
||||
delta_rotation: 0.0,
|
||||
status: Status::InAir,
|
||||
old_status: Status::InAir,
|
||||
status_known: false,
|
||||
water_level: 0.0,
|
||||
land_friction: 0.0,
|
||||
out_of_control: 0.0,
|
||||
@@ -571,6 +574,7 @@ impl Game<'_> {
|
||||
if !controlled && self.remote.is_none() {
|
||||
self.tick_boat_leash(&mut b);
|
||||
}
|
||||
let had_status = std::mem::replace(&mut b.status_known, true);
|
||||
b.old_status = b.status;
|
||||
b.status = self.boat_status(&mut b);
|
||||
b.tick_submersion();
|
||||
@@ -601,7 +605,10 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if b.old_status == Status::InAir && b.status != Status::InAir && b.status != Status::OnLand
|
||||
if had_status
|
||||
&& b.old_status == Status::InAir
|
||||
&& b.status != Status::InAir
|
||||
&& b.status != Status::OnLand
|
||||
{
|
||||
b.water_level = b.position.y;
|
||||
let target = self.water_level_above(&b) - HEIGHT + 0.101;
|
||||
@@ -841,6 +848,8 @@ impl Game<'_> {
|
||||
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
|
||||
let status = self.boat_status(&mut probe);
|
||||
self.boats[i].status = status;
|
||||
// `oldStatus = status` runs on every boat, so the next tick has one.
|
||||
self.boats[i].status_known = true;
|
||||
self.boats[i].tick_submersion();
|
||||
}
|
||||
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
|
||||
|
||||
@@ -311,6 +311,33 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
|
||||
fn position(&mut self) -> Result<Vec3, Infallible> {
|
||||
Ok(self.at())
|
||||
}
|
||||
fn teleport_allowed(&mut self, target: &mut Vec3) -> Result<bool, Infallible> {
|
||||
// EntityTeleportEvent.ChorusFruit: only a server's player is heard.
|
||||
let (Some(uuid), Some(name)) = (self.0.lan_acting_uuid, self.0.lan_acting_name.clone())
|
||||
else {
|
||||
return Ok(true);
|
||||
};
|
||||
let from = self.at();
|
||||
let mut to = [target.x, target.y, target.z];
|
||||
let mut cancel = false;
|
||||
self.0.native_chorus_fruit_teleport(
|
||||
uuid,
|
||||
&name,
|
||||
[from.x, from.y, from.z],
|
||||
&mut to,
|
||||
&mut cancel,
|
||||
);
|
||||
// Keep the mod's spot inside the level as vanilla's own draw is (a
|
||||
// huge y would walk down a long way); one that is not a number keeps
|
||||
// the draw.
|
||||
if to.iter().all(|v| v.is_finite()) {
|
||||
let shape = self.0.level_shapes.get(self.0.dim.id());
|
||||
let high = shape.min_y + shape.logical_height - 1;
|
||||
let y = to[1].clamp(f64::from(shape.min_y), f64::from(high));
|
||||
*target = Vec3::new(to[0], y, to[2]);
|
||||
}
|
||||
Ok(!cancel)
|
||||
}
|
||||
fn random_teleport(&mut self, target: Vec3) -> Result<bool, Infallible> {
|
||||
Ok(self
|
||||
.0
|
||||
|
||||
@@ -1236,7 +1236,7 @@ impl Game<'_> {
|
||||
"distance": self.settings.horizon.lod_distance,
|
||||
"effective_distance": self.lod_distance(),
|
||||
"terrain_distance": self.terrain_distance(),
|
||||
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else { "local" },
|
||||
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else if self.remote.is_some() { "server-terrain" } else { "local" },
|
||||
"source_attempted": self.lod_source_attempted,
|
||||
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
|
||||
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
|
||||
|
||||
@@ -4519,7 +4519,32 @@ impl Game<'_> {
|
||||
{
|
||||
m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]);
|
||||
}
|
||||
if p.owner == Some(Who::Player) && self.player.is_some() {
|
||||
// To where the pearl started its last tick (oldPosition).
|
||||
let mut to = p.previous;
|
||||
let mut damage = 5.0;
|
||||
let mut cancel = false;
|
||||
// EntityTeleportEvent.EnderPearl: a server player's pearl.
|
||||
if p.owner == Some(Who::Player)
|
||||
&& let Some(from) = self.player.as_ref().map(|pl| pl.position)
|
||||
&& let (Some(uuid), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let mut target = [to.x, to.y, to.z];
|
||||
self.native_ender_pearl_teleport(
|
||||
uuid,
|
||||
&name,
|
||||
p.id,
|
||||
[from.x, from.y, from.z],
|
||||
&mut target,
|
||||
&mut damage,
|
||||
&mut cancel,
|
||||
);
|
||||
// A target that is not a number keeps the pearl's.
|
||||
if target.iter().all(|v| v.is_finite()) {
|
||||
to = Vec3::new(target[0], target[1], target[2]);
|
||||
}
|
||||
}
|
||||
if p.owner == Some(Who::Player) && self.player.is_some() && !cancel {
|
||||
// ThrownEnderpearl: 5% endermite where the player was
|
||||
// (when monsters spawn), teleport, 5 damage.
|
||||
if self.world.random.next_float() < 0.05
|
||||
@@ -4530,8 +4555,6 @@ impl Game<'_> {
|
||||
let from = pl.position;
|
||||
self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false);
|
||||
}
|
||||
// To where the pearl started its last tick (oldPosition).
|
||||
let to = p.previous;
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.position = to;
|
||||
pl.velocity = Vec3::ZERO;
|
||||
@@ -4539,7 +4562,9 @@ impl Game<'_> {
|
||||
pl.teleported = true;
|
||||
}
|
||||
self.previous_position = to;
|
||||
self.hurt_player_by("minecraft:ender_pearl", 5.0);
|
||||
if damage > 0.0 {
|
||||
self.hurt_player_by("minecraft:ender_pearl", damage);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.player.teleport",
|
||||
Some([to.x, to.y, to.z]),
|
||||
|
||||
@@ -35,6 +35,17 @@ pub(super) enum ThunderHit {
|
||||
Item,
|
||||
}
|
||||
|
||||
/// What a blast knows about itself, for the entities in its reach to answer.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Blast {
|
||||
/// `VehicleEntity.ignoreExplosion`: a mob's blast, with mob griefing off.
|
||||
pub(super) vehicles_ignore: bool,
|
||||
/// `ItemEntity.ignoreExplosion` (`shouldAffectBlocklikeEntities`).
|
||||
pub(super) items_ignore: bool,
|
||||
/// The exploding mob, which `getEntities(source, box)` leaves out.
|
||||
pub(super) spared: Option<u32>,
|
||||
}
|
||||
|
||||
pub(super) trait Entity {
|
||||
fn position(&self) -> Vec3;
|
||||
fn position_mut(&mut self) -> &mut Vec3;
|
||||
@@ -82,6 +93,32 @@ pub(super) trait Entity {
|
||||
fn thunder_bounds(&self) -> Aabb {
|
||||
self.bounds()
|
||||
}
|
||||
/// `Entity.ignoreExplosion`: the blast neither hurts nor pushes it. Also
|
||||
/// answers for what `getEntities(source, box)` never lists: the exploding
|
||||
/// mob, and the dead.
|
||||
fn ignores_explosion(&self, _blast: &Blast) -> bool {
|
||||
false
|
||||
}
|
||||
/// Whether `Entity.push` moves it. An arrow in the ground, a wind charge and
|
||||
/// an armour stand (`explosion_breaks_decorations` takes those) stay put.
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The box the blast casts its rays at: `getSeenPercent`.
|
||||
fn explosion_box(&self) -> Aabb {
|
||||
self.bounds()
|
||||
}
|
||||
/// How far above its feet `pushFromExplosion` aims: `getEyeHeight`, which
|
||||
/// defaults to 0.85 of the box.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
let b = self.explosion_box();
|
||||
f64::from((b.max_position().y - b.min_position().y) as f32 * 0.85f32)
|
||||
}
|
||||
/// `Entity.push` with the explosion's knockback.
|
||||
fn push_from_explosion(&mut self, push: Vec3) {
|
||||
let v = self.velocity_mut();
|
||||
*v = Vec3::new(v.x + push.x, v.y + push.y, v.z + push.z);
|
||||
}
|
||||
/// `Entity.isSupportedBy`.
|
||||
fn is_supported_by(&self, _pos: BlockPos) -> bool {
|
||||
false
|
||||
@@ -276,6 +313,33 @@ impl Entity for Mob {
|
||||
fn is_ignoring_block_triggers(&self) -> bool {
|
||||
self.species() == Species::Bat
|
||||
}
|
||||
/// `Warden.ignoreExplosion`: while it digs or emerges.
|
||||
fn ignores_explosion(&self, blast: &Blast) -> bool {
|
||||
!self.alive()
|
||||
|| blast.spared == Some(self.id)
|
||||
|| (self.species() == Species::Warden
|
||||
&& horizon_client::mobs::warden::digging_or_emerging(self))
|
||||
}
|
||||
/// The kind's own box, not the baby's or the slime's.
|
||||
fn explosion_box(&self) -> Aabb {
|
||||
let (feet, half) = (self.position, self.kind.width / 2.0);
|
||||
Aabb::new(
|
||||
Vec3::new(feet.x - half, feet.y, feet.z - half),
|
||||
Vec3::new(feet.x + half, feet.y + self.kind.height, feet.z + half),
|
||||
)
|
||||
}
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
self.kind.eye_height
|
||||
}
|
||||
/// `Entity.push` sets `needsSync`.
|
||||
fn push_from_explosion(&mut self, push: Vec3) {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x + push.x,
|
||||
self.velocity.y + push.y,
|
||||
self.velocity.z + push.z,
|
||||
);
|
||||
self.needs_sync = true;
|
||||
}
|
||||
fn is_supported_by(&self, pos: BlockPos) -> bool {
|
||||
self.supporting == Some([pos.x, pos.y, pos.z])
|
||||
}
|
||||
@@ -301,6 +365,12 @@ impl Entity for ItemEntity {
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
ThunderHit::Item
|
||||
}
|
||||
fn ignores_explosion(&self, blast: &Blast) -> bool {
|
||||
blast.items_ignore
|
||||
}
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
0.2125
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
ItemEntity::bounds(self)
|
||||
}
|
||||
@@ -324,6 +394,9 @@ impl Entity for ExperienceOrb {
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
ThunderHit::Orb
|
||||
}
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
f64::from(0.5f32 * 0.85f32)
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
ExperienceOrb::bounds(self)
|
||||
}
|
||||
@@ -347,6 +420,13 @@ impl Entity for Boat {
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
ThunderHit::Vehicle
|
||||
}
|
||||
fn ignores_explosion(&self, blast: &Blast) -> bool {
|
||||
blast.vehicles_ignore
|
||||
}
|
||||
/// `EntityType.OAK_BOAT & co`: the full 0.5625.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
0.5625
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Boat::bounds(self)
|
||||
}
|
||||
@@ -360,6 +440,9 @@ impl Entity for Minecart {
|
||||
fn thunder_hit(&self) -> ThunderHit {
|
||||
ThunderHit::Vehicle
|
||||
}
|
||||
fn ignores_explosion(&self, blast: &Blast) -> bool {
|
||||
blast.vehicles_ignore
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Minecart::bounds(self)
|
||||
}
|
||||
@@ -387,6 +470,9 @@ impl Entity for ArmorStand {
|
||||
fn is_ignoring_block_triggers(&self) -> bool {
|
||||
self.marker
|
||||
}
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `ArmorStand.getPistonPushReaction`: a marker is ignored.
|
||||
fn get_piston_push_reaction(&self) -> PushReaction {
|
||||
if self.marker {
|
||||
@@ -399,6 +485,10 @@ impl Entity for ArmorStand {
|
||||
|
||||
impl Entity for PrimedTnt {
|
||||
stored_motion!();
|
||||
/// Pushed from its position, not its eyes.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
PrimedTnt::bounds(self)
|
||||
}
|
||||
@@ -422,6 +512,22 @@ impl Entity for FallingBlock {
|
||||
|
||||
impl Entity for Projectile {
|
||||
stored_motion!();
|
||||
/// `AbstractArrow.push` does nothing in the ground; a wind charge's motion
|
||||
/// stays 0.
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
!self.stuck && !self.kind.ignores_blast()
|
||||
}
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
self.kind.eye_height()
|
||||
}
|
||||
fn push_from_explosion(&mut self, push: Vec3) {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x + push.x,
|
||||
self.velocity.y + push.y,
|
||||
self.velocity.z + push.z,
|
||||
);
|
||||
self.needs_sync = true;
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Projectile::bounds(self)
|
||||
}
|
||||
@@ -437,6 +543,10 @@ impl Entity for Projectile {
|
||||
|
||||
impl Entity for Rocket {
|
||||
stored_motion!();
|
||||
/// 0.25 wide, so 0.2125 to the eyes.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
0.2125
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
Rocket::bounds(self)
|
||||
}
|
||||
@@ -448,6 +558,14 @@ impl Entity for Rocket {
|
||||
|
||||
impl Entity for ThrownTrident {
|
||||
stored_motion!();
|
||||
/// Like any arrow (`AbstractArrow.push`) not pushed while in the ground.
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
!self.in_ground
|
||||
}
|
||||
/// `EntityType.TRIDENT`: eyes at 0.13.
|
||||
fn explosion_eye_height(&self) -> f64 {
|
||||
f64::from(0.13_f32)
|
||||
}
|
||||
fn bounds(&self) -> Aabb {
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! HashSet, so iteration and the following shuffle match vanilla's random
|
||||
//! draws), entity damage with exposure and knockback, block interaction with
|
||||
//! merged drops and TNT chain priming, and optional fire.
|
||||
use super::Game;
|
||||
use super::{
|
||||
Game,
|
||||
entity::{Blast, Entity, EntityHandle, with_entity, with_entity_mut},
|
||||
};
|
||||
use horizon_client::{
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
|
||||
};
|
||||
@@ -125,6 +128,34 @@ pub(super) fn blast_spares_decorations(
|
||||
source_in_water || (!mob_griefing && (interaction == BlockInteraction::Keep || by_mob))
|
||||
}
|
||||
|
||||
/// How far the blast's `center` is from `p`.
|
||||
fn blast_distance(center: [f64; 3], p: [f64; 3]) -> f64 {
|
||||
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// The unit vector from `center` to `origin`, 0 when they coincide.
|
||||
fn blast_direction(center: [f64; 3], origin: [f64; 3]) -> [f64; 3] {
|
||||
let d = [
|
||||
origin[0] - center[0],
|
||||
origin[1] - center[1],
|
||||
origin[2] - center[2],
|
||||
];
|
||||
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if l < 1.0e-4 {
|
||||
[0.0; 3]
|
||||
} else {
|
||||
[d[0] / l, d[1] / l, d[2] / l]
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity in the blast's reach: how far (as a share of the blast's
|
||||
/// diameter), how much of it the rays see, and the knockback.
|
||||
struct Hit {
|
||||
dist: f64,
|
||||
exposure: f32,
|
||||
push: Vec3,
|
||||
}
|
||||
|
||||
/// A mob in a blast's reach: `hurtServer` when its team allows the damage
|
||||
/// (`hurtable`), then `pushFromExplosion` whatever that returned, so a mob
|
||||
/// that refuses the hit (invulnerable, in its hurt cooldown, a wither and an
|
||||
@@ -472,6 +503,38 @@ impl Game<'_> {
|
||||
pushed
|
||||
}
|
||||
|
||||
/// Where a blast at `center` reaches `entity`, unless it ignores the blast
|
||||
/// or is out of range (`getEntities` and `ServerExplosion.hurtEntities`).
|
||||
/// Generic over the kind, so every walked list gets its own copy.
|
||||
fn blast_hit<E: Entity>(
|
||||
&self,
|
||||
entity: &E,
|
||||
center: [f64; 3],
|
||||
diameter: f64,
|
||||
blast: &Blast,
|
||||
) -> Option<Hit> {
|
||||
if entity.ignores_explosion(blast) || !entity.pushed_by_explosion() {
|
||||
return None;
|
||||
}
|
||||
let feet = entity.position();
|
||||
let dist = blast_distance(center, [feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
return None;
|
||||
}
|
||||
let b = entity.explosion_box();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let dir = blast_direction(
|
||||
center,
|
||||
[feet.x, feet.y + entity.explosion_eye_height(), feet.z],
|
||||
);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
Some(Hit {
|
||||
dist,
|
||||
exposure,
|
||||
push: Vec3::new(dir[0] * power, dir[1] * power, dir[2] * power),
|
||||
})
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
|
||||
/// the player takes `damage_type`. Returns the players whose clients add a
|
||||
/// push from the explosion packet, and how much (`explosion_hurt_player`).
|
||||
@@ -487,27 +550,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.explosion_hits_special(center, radius);
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
let distance = |p: [f64; 3]| {
|
||||
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2))
|
||||
.sqrt()
|
||||
};
|
||||
let damage = |dist: f64, exposure: f32| {
|
||||
let pow = (1.0 - dist) * f64::from(exposure);
|
||||
((pow * pow + pow) / 2.0 * 7.0 * diameter + 1.0) as f32
|
||||
};
|
||||
let direction = |origin: [f64; 3]| {
|
||||
let d = [
|
||||
origin[0] - center[0],
|
||||
origin[1] - center[1],
|
||||
origin[2] - center[2],
|
||||
];
|
||||
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if l < 1.0e-4 {
|
||||
[0.0; 3]
|
||||
} else {
|
||||
[d[0] / l, d[1] / l, d[2] / l]
|
||||
}
|
||||
};
|
||||
// `source.doTeamsAllowDamage(entity)`: the exploding mob spares its
|
||||
// team mates when its team has no friendly fire (they are still pushed).
|
||||
let exploding = self.combat.explosion_source;
|
||||
@@ -563,306 +609,122 @@ impl Game<'_> {
|
||||
.or(exploding)
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.species());
|
||||
// Mobs, but not the one exploding (`getEntities(source, box)`): a
|
||||
// wither's spawn blast neither hurts nor pushes it. `Warden.ignoreExplosion`:
|
||||
// nor does any blast hurt or push a digging or emerging warden.
|
||||
let spared = exploding.filter(|_| self.combat.explosion_source_named);
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
for m in mobs.iter_mut().filter(|m| {
|
||||
m.alive()
|
||||
&& spared != Some(m.id)
|
||||
&& !(m.species() == horizon_client::mobs::Species::Warden
|
||||
&& horizon_client::mobs::warden::digging_or_emerging(m))
|
||||
}) {
|
||||
let hurtable = teams_allow(self, self.mob_team(m.id));
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = (m.kind.width / 2.0, m.kind.height);
|
||||
let eye = [feet[0], feet[1] + m.kind.eye_height, feet[2]];
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - w, feet[1], feet[2] - w],
|
||||
[feet[0] + w, feet[1] + h, feet[2] + w],
|
||||
);
|
||||
// The exploding mob is the damage source's entity, so victims
|
||||
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
// A projectile's owner (`getIndirectSourceEntity`), else
|
||||
// the exploding mob itself.
|
||||
attacker: attacker
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
// `ServerExplosion.hurtEntities`: every entity in reach, on the walk. Each
|
||||
// answers `ignoreExplosion` itself (`Entity::ignores_explosion`); the rest
|
||||
// is the same for all, and only what a blast does to a kind differs. The
|
||||
// walk covers the entities there are now: what the blast drops or spawns
|
||||
// is not hit by it. Destroyed items and orbs are squeezed out afterwards
|
||||
// (as `retain` does), and a destroyed boat or cart shifts its list, so
|
||||
// `gone` counts how far.
|
||||
let blast = Blast {
|
||||
vehicles_ignore: self.combat.blast_owner.is_none()
|
||||
&& self.blast_attacker().is_some()
|
||||
&& !self.ui.rules.bool("mob_griefing"),
|
||||
items_ignore: items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction),
|
||||
spared: exploding.filter(|_| self.combat.explosion_source_named),
|
||||
};
|
||||
let (items_before, orbs_before) = (self.dropped_ids(), self.orb_ids());
|
||||
let (items_len, orbs_len) = (self.dropped.len(), self.orbs.len());
|
||||
let (mut items_kept, mut orbs_kept) = (0, 0);
|
||||
let (mut boats_gone, mut carts_gone) = (0, 0);
|
||||
for handle in self.entity_handles() {
|
||||
let handle = match handle {
|
||||
EntityHandle::Boat(i) => EntityHandle::Boat(i - boats_gone),
|
||||
EntityHandle::Cart(i) => EntityHandle::Cart(i - carts_gone),
|
||||
other => other,
|
||||
};
|
||||
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`: its
|
||||
// base is 0 for every mob type, so iron golems and ravagers are pushed.
|
||||
let dir = direction(eye);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
hurt_and_push_mob(
|
||||
m,
|
||||
hurtable,
|
||||
cause,
|
||||
(damage(dist, exposure), source),
|
||||
[dir[0] * power, dir[1] * power, dir[2] * power],
|
||||
);
|
||||
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast));
|
||||
let mut destroyed = false;
|
||||
if let Some(Hit {
|
||||
dist,
|
||||
exposure,
|
||||
push,
|
||||
}) = hit
|
||||
{
|
||||
let amount = damage(dist, exposure);
|
||||
match handle {
|
||||
EntityHandle::Mob(i) => {
|
||||
let hurtable = teams_allow(self, self.mob_team(self.mobs[i].id));
|
||||
let m = &mut self.mobs[i];
|
||||
// The exploding mob is the damage source's entity, so
|
||||
// victims it kills drop its charged-creeper loot
|
||||
// (`Creeper.killedEntity`).
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
// A projectile's owner (`getIndirectSourceEntity`),
|
||||
// else the exploding mob itself.
|
||||
attacker: attacker
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
};
|
||||
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`:
|
||||
// its base is 0 for every mob type, so iron golems and
|
||||
// ravagers are pushed.
|
||||
hurt_and_push_mob(
|
||||
m,
|
||||
hurtable,
|
||||
cause,
|
||||
(amount, source),
|
||||
[push.x, push.y, push.z],
|
||||
);
|
||||
}
|
||||
// 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
EntityHandle::Item(i) => {
|
||||
destroyed = &*self.dropped[i].stack.item != "minecraft:nether_star"
|
||||
&& amount >= 5.0;
|
||||
if !destroyed {
|
||||
self.dropped[i].push_from_explosion(push);
|
||||
}
|
||||
}
|
||||
// `ExperienceOrb.hurtServer` takes the damage off their 5 health.
|
||||
EntityHandle::Orb(i) => {
|
||||
destroyed = self.orbs[i].hurt(amount);
|
||||
if !destroyed {
|
||||
self.orbs[i].push_from_explosion(push);
|
||||
}
|
||||
}
|
||||
// `VehicleEntity.hurtServer` (explosions ignite TNT carts);
|
||||
// a destroyed one is gone before the push.
|
||||
EntityHandle::Cart(i) => {
|
||||
let (id, len) = (self.carts[i].id, self.carts.len());
|
||||
self.hurt_cart(i, amount, false, true);
|
||||
carts_gone += len - self.carts.len();
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
c.push_from_explosion(push);
|
||||
}
|
||||
}
|
||||
// `VehicleEntity.hurtServer`: damage * 10, broken past 40.
|
||||
EntityHandle::Boat(i) => {
|
||||
let (id, len) = (self.boats[i].id, self.boats.len());
|
||||
self.hurt_boat(i, amount, false);
|
||||
boats_gone += len - self.boats.len();
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.push_from_explosion(push);
|
||||
}
|
||||
}
|
||||
// `hurtServer` does nothing to the rest; `pushFromExplosion` moves them.
|
||||
other => with_entity_mut!(self, other, e => e.push_from_explosion(push)),
|
||||
}
|
||||
}
|
||||
match handle {
|
||||
EntityHandle::Item(i) if !destroyed => {
|
||||
self.dropped.swap(items_kept, i);
|
||||
items_kept += 1;
|
||||
}
|
||||
EntityHandle::Orb(i) if !destroyed => {
|
||||
self.orbs.swap(orbs_kept, i);
|
||||
orbs_kept += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.mobs = mobs;
|
||||
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
// `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities`
|
||||
// first, so a blast that keeps blocks leaves every item untouched.
|
||||
let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction);
|
||||
let before = self.dropped_ids();
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| {
|
||||
if items_ignore {
|
||||
return true;
|
||||
}
|
||||
let feet = [item.position.x, item.position.y, item.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
return true;
|
||||
}
|
||||
let half = 0.125;
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - half, feet[1], feet[2] - half],
|
||||
[feet[0] + half, feet[1] + 0.25, feet[2] + half],
|
||||
);
|
||||
if &*item.stack.item != "minecraft:nether_star" && damage(dist, exposure) >= 5.0 {
|
||||
return false;
|
||||
}
|
||||
let dir = direction([feet[0], feet[1] + 0.2125, feet[2]]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
item.velocity = Vec3::new(
|
||||
item.velocity.x + dir[0] * power,
|
||||
item.velocity.y + dir[1] * power,
|
||||
item.velocity.z + dir[2] * power,
|
||||
);
|
||||
true
|
||||
});
|
||||
self.dropped = items;
|
||||
self.native_items_left_since(&before);
|
||||
// Experience orbs: `ExperienceOrb.hurtServer` takes the damage off
|
||||
// their 5 health, and `pushFromExplosion` moves the ones that stay
|
||||
// (a 0.5 x 0.5 box, the eyes at 0.85 of its height).
|
||||
let orbs_before = self.orb_ids();
|
||||
let mut orbs = std::mem::take(&mut self.orbs);
|
||||
orbs.retain_mut(|o| {
|
||||
let feet = [o.position.x, o.position.y, o.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
return true;
|
||||
}
|
||||
let half = 0.25;
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - half, feet[1], feet[2] - half],
|
||||
[feet[0] + half, feet[1] + 0.5, feet[2] + half],
|
||||
);
|
||||
if o.hurt(damage(dist, exposure)) {
|
||||
return false;
|
||||
}
|
||||
let dir = direction([feet[0], feet[1] + f64::from(0.5f32 * 0.85f32), feet[2]]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
o.velocity = Vec3::new(
|
||||
o.velocity.x + dir[0] * power,
|
||||
o.velocity.y + dir[1] * power,
|
||||
o.velocity.z + dir[2] * power,
|
||||
);
|
||||
true
|
||||
});
|
||||
self.orbs = orbs;
|
||||
self.dropped.drain(items_kept..items_len);
|
||||
self.orbs.drain(orbs_kept..orbs_len);
|
||||
self.native_items_left_since(&items_before);
|
||||
self.native_orbs_left_since(&orbs_before);
|
||||
// Primed TNT: knockback only, from its position (not its eyes).
|
||||
let mut tnts = std::mem::take(&mut self.tnts);
|
||||
for t in &mut tnts {
|
||||
let feet = [t.position.x, t.position.y, t.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let half = 0.49;
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - half, feet[1], feet[2] - half],
|
||||
[feet[0] + half, feet[1] + 0.98, feet[2] + half],
|
||||
);
|
||||
let dir = direction(feet);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x + dir[0] * power,
|
||||
t.velocity.y + dir[1] * power,
|
||||
t.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
// `VehicleEntity.ignoreExplosion`: a mob's blast (not a primed TNT's)
|
||||
// spares boats and minecarts while mob griefing is off.
|
||||
let vehicles_ignore = self.combat.blast_owner.is_none()
|
||||
&& self.blast_attacker().is_some()
|
||||
&& !self.ui.rules.bool("mob_griefing");
|
||||
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
|
||||
// then the push from their eyes (0.85 of their height).
|
||||
let ids: Vec<u32> = if vehicles_ignore {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.carts.iter().map(|c| c.id).collect()
|
||||
};
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = self.carts[i].bounds();
|
||||
let feet = self.carts[i].position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
self.hurt_cart(i, damage(dist, exposure), false, true);
|
||||
let Some(c) = self.carts.iter_mut().find(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let eye_height = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
let dir = direction([feet.x, feet.y + eye_height, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
c.velocity = Vec3::new(
|
||||
c.velocity.x + dir[0] * power,
|
||||
c.velocity.y + dir[1] * power,
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
// Boats: `VehicleEntity.hurtServer` (damage * 10, broken past 40), then the
|
||||
// push from their eyes (`EntityType.OAK_BOAT & co`: the full 0.5625).
|
||||
let ids: Vec<u32> = if vehicles_ignore {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.boats.iter().map(|b| b.id).collect()
|
||||
};
|
||||
for id in ids {
|
||||
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = self.boats[i].bounds();
|
||||
let feet = self.boats[i].position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
self.hurt_boat(i, damage(dist, exposure), false);
|
||||
let Some(b) = self.boats.iter_mut().find(|b| b.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let dir = direction([feet.x, feet.y + 0.5625, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
b.velocity = Vec3::new(
|
||||
b.velocity.x + dir[0] * power,
|
||||
b.velocity.y + dir[1] * power,
|
||||
b.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
// Falling blocks: `Entity.hurtServer` does nothing, `pushFromExplosion`
|
||||
// moves them from their eyes (0.85 of the 0.98 box).
|
||||
let mut falling = std::mem::take(&mut self.falling);
|
||||
for f in &mut falling {
|
||||
let feet = f.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let b = horizon_client::falling_block::bounds(feet);
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let eye = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
let dir = direction([feet.x, feet.y + eye, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
f.velocity = Vec3::new(
|
||||
f.velocity.x + dir[0] * power,
|
||||
f.velocity.y + dir[1] * power,
|
||||
f.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.falling = falling;
|
||||
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
|
||||
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
|
||||
// does nothing while it is in the ground; `hurtServer` never hurts them.
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
for p in projectiles
|
||||
.iter_mut()
|
||||
.filter(|p| !p.stuck && !p.kind.ignores_blast())
|
||||
{
|
||||
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
|
||||
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let eye_height = p.kind.eye_height();
|
||||
let dir = direction([p.position.x, p.position.y + eye_height, p.position.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
p.velocity = Vec3::new(
|
||||
p.velocity.x + dir[0] * power,
|
||||
p.velocity.y + dir[1] * power,
|
||||
p.velocity.z + dir[2] * power,
|
||||
);
|
||||
p.needs_sync = true;
|
||||
}
|
||||
self.creatures.projectiles = projectiles;
|
||||
// Firework rockets (0.25 wide, so 0.2125 to the eyes).
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in &mut rockets {
|
||||
let feet = r.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet.x - 0.125, feet.y, feet.z - 0.125],
|
||||
[feet.x + 0.125, feet.y + 0.25, feet.z + 0.125],
|
||||
);
|
||||
let dir = direction([feet.x, feet.y + 0.2125, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
r.velocity = Vec3::new(
|
||||
r.velocity.x + dir[0] * power,
|
||||
r.velocity.y + dir[1] * power,
|
||||
r.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
// Thrown tridents live apart from the other projectiles (`game/trident.rs`): 0.5
|
||||
// wide and high, eyes at 0.13 (`EntityType.TRIDENT`), and like any arrow
|
||||
// (`AbstractArrow.push`) not pushed while in the ground.
|
||||
let mut tridents = std::mem::take(&mut self.tridents);
|
||||
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
|
||||
let feet = t.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet.x - 0.25, feet.y, feet.z - 0.25],
|
||||
[feet.x + 0.25, feet.y + 0.5, feet.z + 0.25],
|
||||
);
|
||||
let dir = direction([feet.x, feet.y + f64::from(0.13_f32), feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x + dir[0] * power,
|
||||
t.velocity.y + dir[1] * power,
|
||||
t.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.tridents = tridents;
|
||||
// Frames, paintings, cushions and armour stands last: vanilla lists
|
||||
// the entities in reach before hurting any, so the items they drop
|
||||
// here are neither destroyed nor pushed by the same blast.
|
||||
|
||||
@@ -1821,7 +1821,10 @@ impl Game<'_> {
|
||||
e.position = [d.position.x, d.position.y, d.position.z];
|
||||
e.on_ground = d.on_ground;
|
||||
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
|
||||
e.precise = d.needs_sync;
|
||||
// `ServerEntity.getRequiresPrecisePosition` is false for an item:
|
||||
// `needs_sync` asks for its data again, not for an exact position.
|
||||
// The collision case is `full_precision_required` in the tracker.
|
||||
e.precise = false;
|
||||
e.needs_sync = d.needs_sync;
|
||||
e.vertical_collision = d.vertical_collision;
|
||||
e.horizontal_collision = d.horizontal_collision;
|
||||
|
||||
@@ -391,11 +391,6 @@ impl Game<'_> {
|
||||
.get(self.world.block(x, y, z))
|
||||
.full_collision
|
||||
}
|
||||
/// Whether the cart's box touches water where it is now
|
||||
/// (`EntityFluidInteraction.update`).
|
||||
fn touches_water(&self, c: &Minecart) -> bool {
|
||||
self.bounds_in_water(c.bounds())
|
||||
}
|
||||
/// `OldMinecartBehavior.getPos`: the point on the rail under (x, y, z).
|
||||
fn rail_pos(&self, x: f64, y: f64, z: f64) -> Option<[f64; 3]> {
|
||||
let [xt, yt, zt] = self.rail_block(Vec3::new(x, y, z));
|
||||
@@ -544,6 +539,9 @@ impl Game<'_> {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
// Entity.move ends with applyEffectsFromBlocks: a bubble column lifts
|
||||
// or drags the cart (tick_cart runs it a second time, like vanilla).
|
||||
self.cart_block_effects(c);
|
||||
}
|
||||
fn max_speed(&self, c: &Minecart) -> f64 {
|
||||
let base = if c.in_water { 0.2 } else { 0.4 };
|
||||
@@ -962,6 +960,33 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `applyEffectsFromBlocks` for a cart: a bubble column lifts or drags it,
|
||||
/// and `onInsideBubbleColumn` resets the fall distance.
|
||||
fn cart_block_effects(&self, c: &mut Minecart) {
|
||||
if horizon_client::motion::bubble_column_push(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
c.bounds(),
|
||||
&mut c.velocity,
|
||||
) {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
/// Runs `push_from_cart` for the cart being ticked (`c` is a copy of the
|
||||
/// stored one): the stored cart is replaced by `c` for the push and `c`
|
||||
/// takes back what the push changed.
|
||||
fn push_from_ticked_cart(&mut self, c: &mut Minecart) {
|
||||
let Some(i) = self.carts.iter().position(|x| x.id == c.id) else {
|
||||
return;
|
||||
};
|
||||
self.carts[i] = c.clone();
|
||||
self.push_from_cart(i);
|
||||
if let Some(i) = self.carts.iter().position(|x| x.id == c.id) {
|
||||
*c = self.carts[i].clone();
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
|
||||
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
|
||||
c.first_tick = false;
|
||||
@@ -1001,8 +1026,25 @@ impl Game<'_> {
|
||||
c.flipped = !c.flipped;
|
||||
}
|
||||
c.yaw %= 360.0;
|
||||
// updateFluidInteraction, after the move.
|
||||
c.in_water = self.touches_water(c);
|
||||
// applyEffectsFromBlocks: a bubble column lifts or drags the cart.
|
||||
self.cart_block_effects(c);
|
||||
// OldMinecartBehavior.tick ends with pushAndPickupEntities, before
|
||||
// AbstractMinecart.tick's updateFluidInteraction.
|
||||
self.push_from_ticked_cart(c);
|
||||
// AbstractMinecart.tick: updateFluidInteraction, a stream pushes the cart.
|
||||
c.in_water = horizon_client::entities::update_entity_fluids(
|
||||
c.bounds(),
|
||||
c.position,
|
||||
c.bounds().max_position().y,
|
||||
&mut c.velocity,
|
||||
&self.world,
|
||||
&self.res.motion().fluids,
|
||||
)
|
||||
.0 > 0.0;
|
||||
// updateFluidInteraction: being in water resets the fall distance.
|
||||
if c.in_water {
|
||||
c.fall_distance = 0.0;
|
||||
}
|
||||
// Variants.
|
||||
match c.kind {
|
||||
CartKind::Furnace => {
|
||||
@@ -1428,7 +1470,6 @@ impl Game<'_> {
|
||||
if alive {
|
||||
let due = std::mem::take(&mut c.command_due);
|
||||
self.carts[i] = c;
|
||||
self.push_from_cart(i);
|
||||
if due {
|
||||
self.perform_cart_command(id);
|
||||
}
|
||||
@@ -1542,7 +1583,6 @@ impl Game<'_> {
|
||||
};
|
||||
if alive {
|
||||
self.carts[i] = c;
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.eject_cart_mobs(i);
|
||||
self.release_vehicle_container(c.id);
|
||||
|
||||
@@ -3215,6 +3215,67 @@ impl Game<'_> {
|
||||
pub(super) fn native_perform_console_command(&mut self, command: &str, ran: &mut bool) {
|
||||
mod_api::dispatch_perform_console_command(self, mod_api::mods(), command, ran);
|
||||
}
|
||||
/// A server player's ender pearl landed and is about to teleport them
|
||||
/// (NeoForge's `EntityTeleportEvent.EnderPearl`, vanilla's
|
||||
/// `ThrownEnderpearl.onHit` for a player owner): `pearl` is the pearl's
|
||||
/// entity id, `from` their feet now, `target` where the pearl started
|
||||
/// its last tick and `damage` the fall damage they then take (5). A mod
|
||||
/// may move `target` (a value that is not a number is ignored) or change
|
||||
/// `damage` (0 or less: none). Canceling
|
||||
/// keeps the player where they are: no endermite roll, no teleport, no
|
||||
/// damage and no sound; the pearl is used up either way. The player's
|
||||
/// state is swapped in (`native_acting_player`). The hit itself is
|
||||
/// heard by `native_projectile_impact`. @API:ender_pearl_teleport@.
|
||||
pub(super) fn native_ender_pearl_teleport(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
pearl: u32,
|
||||
from: [f64; 3],
|
||||
target: &mut [f64; 3],
|
||||
damage: &mut f32,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_ender_pearl_teleport(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
pearl,
|
||||
from,
|
||||
target,
|
||||
damage,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
/// A server player who ate chorus fruit (or another item with the
|
||||
/// `teleport_randomly` consume effect) is about to try a random spot
|
||||
/// (NeoForge's `EntityTeleportEvent.ChorusFruit`, vanilla's
|
||||
/// `TeleportRandomlyConsumeEffect.apply`): once per attempt (up to 16),
|
||||
/// after they stopped riding. `from` is their feet, `target` the spot
|
||||
/// drawn; a mod may move it (its y kept inside the level's height, a
|
||||
/// value that is not a number ignored), and the game still looks down
|
||||
/// from it for ground it may land on. Canceling ends the effect: no teleport and no
|
||||
/// more attempts. The player's state is swapped in
|
||||
/// (`native_acting_player`). Foxes are not heard. @API:chorus_fruit_teleport@.
|
||||
pub(super) fn native_chorus_fruit_teleport(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
from: [f64; 3],
|
||||
target: &mut [f64; 3],
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_chorus_fruit_teleport(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
from,
|
||||
target,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
/// A server player a command teleported (NeoForge's
|
||||
/// `EntityTeleportEvent`, for a player): `/tp` and `/teleport`, inside
|
||||
/// their level or into another one. `from` and `to` are their feet before
|
||||
@@ -3223,9 +3284,9 @@ impl Game<'_> {
|
||||
/// teleport: when the new level is entered, before it has loaded around
|
||||
/// them), so it cannot stop it. The player's state is swapped in, so the
|
||||
/// helpers that read the acting player and `native_tell` work. Once for
|
||||
/// each teleported player, on a server. Other teleports are not heard
|
||||
/// yet: ender pearls, chorus fruit, a spectator's teleport menu; portals
|
||||
/// and respawns are not teleports here. @API:native_player_teleported@.
|
||||
/// each teleported player, on a server. Ender pearls and chorus fruit
|
||||
/// have their own hooks; a spectator's teleport menu is not heard yet;
|
||||
/// portals and respawns are not teleports here. @API:native_player_teleported@.
|
||||
pub(super) fn native_player_teleported(
|
||||
&mut self,
|
||||
player: u128,
|
||||
|
||||
@@ -450,6 +450,11 @@ pub(super) struct Remote {
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
pub(super) use_item: Option<Arc<str>>,
|
||||
pub(super) dimension: DimensionType,
|
||||
/// The level's key (`minecraft:overworld`): where its far terrain is kept.
|
||||
pub(super) level: String,
|
||||
/// The level's hashed seed (login, respawn): the folder of its kept chunks
|
||||
/// (`server_terrain::level_dir`).
|
||||
pub(super) hashed_seed: i64,
|
||||
/// What chunk packets decode with (the dimension's height and sky, the
|
||||
/// biome registry); replaced whenever one of them changes.
|
||||
pub(super) chunk_format: Arc<ChunkFormat>,
|
||||
@@ -960,6 +965,8 @@ impl Game<'_> {
|
||||
camera: None,
|
||||
use_item: None,
|
||||
dimension: DimensionType::vanilla("minecraft:overworld"),
|
||||
level: "minecraft:overworld".into(),
|
||||
hashed_seed: 0,
|
||||
chunk_format: Arc::new(ChunkFormat::new(
|
||||
&DimensionType::vanilla("minecraft:overworld"),
|
||||
Vec::new(),
|
||||
@@ -2032,6 +2039,8 @@ impl Game<'_> {
|
||||
.in_column(shape.column()),
|
||||
);
|
||||
r.dimension = dimension;
|
||||
r.level = spawn.dimension.clone();
|
||||
r.hashed_seed = spawn.seed;
|
||||
r.game_mode = spawn.game_type;
|
||||
r.flat = spawn.flat;
|
||||
r.loaded_sent = false;
|
||||
@@ -2139,6 +2148,19 @@ impl Game<'_> {
|
||||
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
|
||||
})
|
||||
}
|
||||
/// A column the client no longer holds; far terrain keeps it as it was
|
||||
/// last (`server_terrain`).
|
||||
fn forget_remote_chunk(
|
||||
&mut self,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> Option<std::sync::Arc<horizon_client::chunk::Chunk>> {
|
||||
let chunk = self.streaming.forget(&mut self.world, x, z)?;
|
||||
if let Some(lod) = &mut self.lod {
|
||||
lod.observe(chunk.clone(), self.res.facts());
|
||||
}
|
||||
Some(chunk)
|
||||
}
|
||||
/// The columns a moved or resized storage no longer holds.
|
||||
fn drop_remote_chunks_out_of_range(&mut self) {
|
||||
let mut gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
|
||||
@@ -2162,7 +2184,7 @@ impl Game<'_> {
|
||||
}
|
||||
let mut freed = Vec::with_capacity(gone.len());
|
||||
for &(x, z) in gone.iter() {
|
||||
freed.extend(self.streaming.forget(&mut self.world, x, z));
|
||||
freed.extend(self.forget_remote_chunk(x, z));
|
||||
}
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
renderer.remove_columns(gone.iter().copied());
|
||||
|
||||
@@ -62,11 +62,13 @@ impl PrimedTnt {
|
||||
let moved = scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
if horizon_client::entities::move_applies(wanted, moved) {
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
}
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
if moved.x != wanted.x {
|
||||
self.velocity.x = 0.0;
|
||||
@@ -75,7 +77,19 @@ impl PrimedTnt {
|
||||
self.velocity.z = 0.0;
|
||||
}
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
// Entity.restituteMovementAfterCollisions: slime bounces it.
|
||||
let below = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y - f64::from(1.0e-5_f32)).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
self.velocity.y = horizon_client::entities::vertical_collision_velocity(
|
||||
wanted.y,
|
||||
moved.y,
|
||||
0.04,
|
||||
wanted.y < 0.0,
|
||||
&*facts.get(below).name == "minecraft:slime_block",
|
||||
);
|
||||
}
|
||||
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it, and the
|
||||
// columns it is at the top of send their surface particles.
|
||||
@@ -89,6 +103,20 @@ impl PrimedTnt {
|
||||
&mut self.velocity,
|
||||
|pos| tops.push(pos),
|
||||
);
|
||||
if self.on_ground {
|
||||
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn.
|
||||
let under = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y - f64::from(0.2_f32)).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
if &*facts.get(under).name == "minecraft:slime_block"
|
||||
&& let Some(scale) = horizon_client::entities::slime_step_on_scale(self.velocity.y)
|
||||
{
|
||||
self.velocity.x *= scale;
|
||||
self.velocity.z *= scale;
|
||||
}
|
||||
}
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.98,
|
||||
self.velocity.y * 0.98,
|
||||
|
||||
@@ -1204,6 +1204,23 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// `ServerEntity.getRequiresPrecisePosition` is false for an item, so a
|
||||
/// drop whose data has to be sent again still moves by a delta; only the
|
||||
/// collision case takes the exact-position packet.
|
||||
#[test]
|
||||
fn an_item_that_needs_its_data_sent_again_still_moves_by_a_delta() {
|
||||
let mut e = state(0.0);
|
||||
e.kind = "minecraft:item".into();
|
||||
e.position = [0.3, 64.0, 0.3];
|
||||
let mut s = added(&e);
|
||||
e.needs_sync = true;
|
||||
e.position = [0.7, 64.0, 0.3];
|
||||
assert!(matches!(
|
||||
changes(&mut s, &e)[..],
|
||||
[play::Clientbound::MoveEntityPos(_)]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fireballs_bundle_their_motion_with_their_power() {
|
||||
let mut e = state(0.0);
|
||||
|
||||
+125
-3
@@ -2629,6 +2629,27 @@ pub trait Hooks: Send + Sync {
|
||||
let _ = event;
|
||||
Flow::Continue
|
||||
}
|
||||
/// A server player who ate chorus fruit (or another item with the
|
||||
/// `teleport_randomly` consume effect) is about to try a random spot
|
||||
/// (NeoForge's `EntityTeleportEvent.ChorusFruit`, vanilla's
|
||||
/// `TeleportRandomlyConsumeEffect.apply`): once per attempt (up to 16),
|
||||
/// after they stopped riding. `from` is their feet, `target` the spot
|
||||
/// drawn; a mod may move it (its y kept inside the level's height, a
|
||||
/// value that is not a number ignored), and the game still looks down
|
||||
/// from it for ground it may land on. Canceling ends the effect: no teleport and no
|
||||
/// more attempts. The player's state is swapped in
|
||||
/// (`native_acting_player`). Foxes are not heard. @API:chorus_fruit_teleport@.
|
||||
///
|
||||
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
|
||||
fn chorus_fruit_teleport(
|
||||
&self,
|
||||
host: &mut dyn Host,
|
||||
event: &mut ChorusFruitTeleportEvent<'_>,
|
||||
) -> Flow {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
Flow::Continue
|
||||
}
|
||||
/// A compostable item was added to the composter at `pos`. @API:compost_item@.
|
||||
fn compost_item(&self, host: &dyn Host, event: &mut CompostItemEvent<'_>) {
|
||||
let _ = host;
|
||||
@@ -2674,6 +2695,28 @@ pub trait Hooks: Send + Sync {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// A server player's ender pearl landed and is about to teleport them
|
||||
/// (NeoForge's `EntityTeleportEvent.EnderPearl`, vanilla's
|
||||
/// `ThrownEnderpearl.onHit` for a player owner): `pearl` is the pearl's
|
||||
/// entity id, `from` their feet now, `target` where the pearl started
|
||||
/// its last tick and `damage` the fall damage they then take (5). A mod
|
||||
/// may move `target` (a value that is not a number is ignored) or change
|
||||
/// `damage` (0 or less: none). Canceling
|
||||
/// keeps the player where they are: no endermite roll, no teleport, no
|
||||
/// damage and no sound; the pearl is used up either way. The player's
|
||||
/// state is swapped in (`native_acting_player`). The hit itself is
|
||||
/// heard by `native_projectile_impact`. @API:ender_pearl_teleport@.
|
||||
///
|
||||
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
|
||||
fn ender_pearl_teleport(
|
||||
&self,
|
||||
host: &mut dyn Host,
|
||||
event: &mut EnderPearlTeleportEvent<'_>,
|
||||
) -> Flow {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
Flow::Continue
|
||||
}
|
||||
/// Entities from `chunk` finished loading into the server world. `count`
|
||||
/// is the number of saved entity records read. @API:native_entities_load@.
|
||||
fn entities_load(&self, host: &mut dyn Host, event: &mut EntitiesLoadEvent<'_>) {
|
||||
@@ -3398,9 +3441,9 @@ pub trait Hooks: Send + Sync {
|
||||
/// teleport: when the new level is entered, before it has loaded around
|
||||
/// them), so it cannot stop it. The player's state is swapped in, so the
|
||||
/// helpers that read the acting player and `native_tell` work. Once for
|
||||
/// each teleported player, on a server. Other teleports are not heard
|
||||
/// yet: ender pearls, chorus fruit, a spectator's teleport menu; portals
|
||||
/// and respawns are not teleports here. @API:native_player_teleported@.
|
||||
/// each teleported player, on a server. Ender pearls and chorus fruit
|
||||
/// have their own hooks; a spectator's teleport menu is not heard yet;
|
||||
/// portals and respawns are not teleports here. @API:native_player_teleported@.
|
||||
fn player_teleported(&self, host: &mut dyn Host, event: &mut PlayerTeleportedEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
@@ -8944,6 +8987,42 @@ pub fn dispatch_cauldron_level_changed(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::chorus_fruit_teleport`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ChorusFruitTeleportEvent<'a> {
|
||||
pub player: u128,
|
||||
pub name: &'a str,
|
||||
pub from: [f64; 3],
|
||||
pub target: &'a mut [f64; 3],
|
||||
}
|
||||
pub fn dispatch_chorus_fruit_teleport(
|
||||
host: &mut dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
player: u128,
|
||||
name: &str,
|
||||
from: [f64; 3],
|
||||
target: &mut [f64; 3],
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
if *cancel {
|
||||
return;
|
||||
}
|
||||
let mut event = ChorusFruitTeleportEvent {
|
||||
player,
|
||||
name,
|
||||
from,
|
||||
target,
|
||||
};
|
||||
for mod_hook in mods {
|
||||
if matches!(
|
||||
mod_hook.chorus_fruit_teleport(host, &mut event),
|
||||
Flow::Cancel
|
||||
) {
|
||||
*cancel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::compost_item`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct CompostItemEvent<'a> {
|
||||
@@ -9083,6 +9162,49 @@ pub fn dispatch_enchant_item(
|
||||
mod_hook.enchant_item(host, &mut event);
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::ender_pearl_teleport`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct EnderPearlTeleportEvent<'a> {
|
||||
pub player: u128,
|
||||
pub name: &'a str,
|
||||
pub pearl: u32,
|
||||
pub from: [f64; 3],
|
||||
pub target: &'a mut [f64; 3],
|
||||
pub damage: f32,
|
||||
}
|
||||
pub fn dispatch_ender_pearl_teleport(
|
||||
host: &mut dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
player: u128,
|
||||
name: &str,
|
||||
pearl: u32,
|
||||
from: [f64; 3],
|
||||
target: &mut [f64; 3],
|
||||
damage: &mut f32,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
if *cancel {
|
||||
return;
|
||||
}
|
||||
let mut event = EnderPearlTeleportEvent {
|
||||
player,
|
||||
name,
|
||||
pearl,
|
||||
from,
|
||||
target,
|
||||
damage: *damage,
|
||||
};
|
||||
for mod_hook in mods {
|
||||
if matches!(
|
||||
mod_hook.ender_pearl_teleport(host, &mut event),
|
||||
Flow::Cancel
|
||||
) {
|
||||
*cancel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
*damage = event.damage;
|
||||
}
|
||||
/// What [`Hooks::entities_load`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct EntitiesLoadEvent<'a> {
|
||||
|
||||
@@ -26,6 +26,13 @@ pub trait TeleportConsumeWorld {
|
||||
fn is_passenger(&mut self) -> Result<bool, Self::Error>;
|
||||
fn stop_riding(&mut self) -> Result<(), Self::Error>;
|
||||
fn position(&mut self) -> Result<Vec3, Self::Error>;
|
||||
/// Horizon's mods (NeoForge's `EntityTeleportEvent.ChorusFruit`): before
|
||||
/// each attempt, after the user stopped riding. They may move `target`;
|
||||
/// false stops the effect without teleporting and without more draws.
|
||||
fn teleport_allowed(&mut self, target: &mut Vec3) -> Result<bool, Self::Error> {
|
||||
let _ = target;
|
||||
Ok(true)
|
||||
}
|
||||
/// showParticles=true and forbidden tag=CONSUMABLE_DOES_NOT_TELEPORT_TO.
|
||||
fn random_teleport(&mut self, position: Vec3) -> Result<bool, Self::Error>;
|
||||
/// Level TELEPORT event, old position, context containing this actor.
|
||||
@@ -91,7 +98,11 @@ impl TeleportConsume {
|
||||
world.stop_riding()?;
|
||||
}
|
||||
let old = world.position()?;
|
||||
if world.random_teleport(Vec3::new(x, y, z))? {
|
||||
let mut target = Vec3::new(x, y, z);
|
||||
if !world.teleport_allowed(&mut target)? {
|
||||
return Ok(false);
|
||||
}
|
||||
if world.random_teleport(target)? {
|
||||
world.teleport_event(level, old)?;
|
||||
let fox = world.is_fox();
|
||||
world.teleport_sound(level, world.position_field(), fox)?;
|
||||
@@ -116,3 +127,121 @@ impl TeleportConsume {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::convert::Infallible;
|
||||
|
||||
/// A user at the origin whose every landing fails unless `lands`; it
|
||||
/// records the targets tried and how the mods answer.
|
||||
struct Stub {
|
||||
random: LegacyRandom,
|
||||
allow: fn(&mut Vec3) -> bool,
|
||||
lands: bool,
|
||||
asked: usize,
|
||||
tried: Vec<Vec3>,
|
||||
}
|
||||
impl TeleportConsumeWorld for Stub {
|
||||
type Error = Infallible;
|
||||
type Level = ();
|
||||
type Random = ();
|
||||
type Source = LegacyRandom;
|
||||
fn position_field(&self) -> Vec3 {
|
||||
Vec3::ZERO
|
||||
}
|
||||
fn get_random(&mut self) -> Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
fn random_source(&mut self, (): ()) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
fn min_y(&mut self, (): ()) -> Result<i32, Infallible> {
|
||||
Ok(-64)
|
||||
}
|
||||
fn logical_height(&mut self, (): ()) -> Result<i32, Infallible> {
|
||||
Ok(384)
|
||||
}
|
||||
fn is_passenger(&mut self) -> Result<bool, Infallible> {
|
||||
Ok(false)
|
||||
}
|
||||
fn stop_riding(&mut self) -> Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
fn position(&mut self) -> Result<Vec3, Infallible> {
|
||||
Ok(Vec3::ZERO)
|
||||
}
|
||||
fn teleport_allowed(&mut self, target: &mut Vec3) -> Result<bool, Infallible> {
|
||||
self.asked += 1;
|
||||
Ok((self.allow)(target))
|
||||
}
|
||||
fn random_teleport(&mut self, position: Vec3) -> Result<bool, Infallible> {
|
||||
self.tried.push(position);
|
||||
Ok(self.lands)
|
||||
}
|
||||
fn teleport_event(&mut self, (): (), _: Vec3) -> Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
fn is_fox(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn teleport_sound(&mut self, (): (), _: Vec3, _: bool) -> Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
fn block_position(&mut self) -> Result<BlockPos, Infallible> {
|
||||
Ok(BlockPos::new(0, 0, 0))
|
||||
}
|
||||
fn directional_particles(&mut self, (): (), _: BlockPos, _: i32) -> Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
fn reset_fall_distance(&mut self) -> Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
fn reset_impulse(&mut self) -> Result<(), Infallible> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn stub(allow: fn(&mut Vec3) -> bool, lands: bool) -> Stub {
|
||||
Stub {
|
||||
random: LegacyRandom::new(7),
|
||||
allow,
|
||||
lands,
|
||||
asked: 0,
|
||||
tried: Vec::new(),
|
||||
}
|
||||
}
|
||||
const CHORUS: TeleportConsume = TeleportConsume {
|
||||
diameter: 16.0,
|
||||
directional_particles: false,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn a_refused_attempt_ends_the_effect_without_teleporting() {
|
||||
let mut w = stub(|_| false, true);
|
||||
assert_eq!(CHORUS.apply(&mut w, ()), Ok(false));
|
||||
assert_eq!((w.asked, w.tried.len()), (1, 0), "asked once, never tried");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mods_target_is_where_the_user_tries_to_land() {
|
||||
let mut w = stub(
|
||||
|t| {
|
||||
*t = Vec3::new(100.5, 70.0, -3.5);
|
||||
true
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert_eq!(CHORUS.apply(&mut w, ()), Ok(true));
|
||||
assert_eq!(w.tried, [Vec3::new(100.5, 70.0, -3.5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_mods_all_sixteen_attempts_stay_vanillas() {
|
||||
let mut w = stub(|_| true, false);
|
||||
assert_eq!(CHORUS.apply(&mut w, ()), Ok(false));
|
||||
assert_eq!((w.asked, w.tried.len()), (16, 16));
|
||||
// Each target is within half the diameter of the user.
|
||||
assert!(w.tried.iter().all(|t| t.x.abs() <= 8.0 && t.z.abs() <= 8.0));
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,14 @@ and the container screen tests are unchanged).
|
||||
- `chatIndicator` off removes the coloured bar at the left of a line. It hides
|
||||
the bar only, never the message.
|
||||
|
||||
### Scroll wheel changes settings (`wheelCyclesSettings`, default on)
|
||||
- On, the wheel works as in vanilla: over a button in the settings it changes the button's value
|
||||
(`CycleButton.mouseScrolled`), and a button focused with the keyboard or a pad steps on the
|
||||
wheel wherever the cursor is.
|
||||
- Off, the wheel only scrolls the list under the cursor, on every settings page, Video's included
|
||||
(`OptionsUi::wheel`, `GraphicsUi::wheel`). Scrolling through a long page then can't change an
|
||||
option that passes under the cursor (#5455). Client-only.
|
||||
|
||||
### Splitscreen (`splitscreen`, default off)
|
||||
|
||||
**Options → Horizon Settings... → Splitscreen...** turns local joining on or off at once.
|
||||
|
||||
+3
-1
@@ -63,11 +63,13 @@ A full opaque cube means `RenderShape::Model`, `can_occlude`, `solid_render` and
|
||||
|
||||
Distant terrain uses progressively coarser voxel columns. Columns retain separate material layers and air gaps; matching exposed faces are merged, with block-scale texture repeats. Complete chunk data is reduced once on the terrain worker and cached at each supported scale, across each cell’s area instead of enlarging its corner column. Material votes combine different light levels of the same block; reduced cells retain the brightest sampled sky and block light separately. Finer tiles replace only their own footprints in a loading fallback, and a movement margin limits rapid resolution changes at tile boundaries. When Full Detail Distance shrinks, the chunks it no longer covers stay drawn until the tiles planned there are ready, so the coarsest loaded tiles never stand in for them. Local Overworld, Nether and End worlds and a LAN world you host can supply their generator. Remote servers do not supply the client's world-generation seed or generator configuration.
|
||||
|
||||
On a server that doesn't sample far terrain for you (any vanilla, Paper or Spigot server; Horizon servers that do are below), distant terrain is what the server has sent: every chunk you load is kept as its reduced columns in `server-terrain/<server>/<seed>/<namespace>/<dimension>/` (Anvil region files, about a sector a chunk; `<seed>` is the hashed seed the server sends, which keeps the worlds behind one address apart) in the game folder, and drawn beyond the server's view distance on this visit and later ones. Nothing is pruned: delete `server-terrain/<server>` to forget a server (a server whose plugin sends another hashed seed at every login starts a new folder each time; the worlds of a proxy whose servers all hide their seed as 0 share one). A friend's world is not kept (its invite code changes at every share), nor a splitscreen guest's (it is the player's own world). Areas you have never loaded stay empty; far off, where a cell of far terrain is wider than a chunk, it is drawn from the kept chunk nearest its middle. A chunk the server sends again replaces the kept one; changes made while you are away show on your next visit. The kept columns are tied to the game version; after an update a server's terrain fills in again as you play.
|
||||
|
||||
Unknown areas are sampled directly from the dimension's seed-bound density and surface rules, with shared per-tile scratch and material evaluation. A small pool samples tiles independently of meshing. Refinement prioritizes visible screen error; high aerial views use the distance to the world height range to avoid unnecessary close detail below the camera. Initial vegetation uses the world's configured tree types, placement densities and biome assignments at every distance level, and chorus plants on the End's end stone in the same way. The End's small islands are estimated above the void from the `end_island_decorated` feature. Tree, chorus and island positions, shapes and large-cell coverage are visual approximations, not a replay of native feature placement; chorus is drawn only while its cells stay small enough to read as a plant.
|
||||
|
||||
A separate bounded worker refines the nearest LOD levels with disposable ordinary chunk generation, including supported carvers, structures and decoration. Groups of 4×4 chunks share their generation halos, and each chunk is published once every neighbouring feature center has completed. Farther tiles use direct sampling rather than waiting for every intervening ordinary chunk. Saved and visited blocks take precedence over both paths. These temporary chunks never enter gameplay, simulation or normal save queues. Generated-column caches and request queues are bounded; far data is regenerated after reopening a world.
|
||||
|
||||
On a dedicated server, the client has no generator of the world: the server samples the far tiles for it over Horizon's own `horizon:lod` channel, out to the distance its owner allows (`horizon-lod-distance`, off unless the owner sets it: see [hosting](hosting.md)), and the client's LOD Distance stops there. Those tiles are sampled from the generator, without the decoration refinement above; the chunks the server sent are summarized into far terrain as in single player. A friend's open-to-LAN world doesn't offer far terrain yet: it has no setting for that distance.
|
||||
On a Horizon dedicated server that offers it, the client has no generator of the world: the server samples the far tiles for it over Horizon's own `horizon:lod` channel, out to the distance its owner allows (`horizon-lod-distance`, off unless the owner sets it: see [hosting](hosting.md)), and the client's LOD Distance stops there. Those tiles are sampled from the generator, without the decoration refinement above; the chunks the server sent are summarized into far terrain as in single player. A friend's open-to-LAN world doesn't offer far terrain yet: it has no setting for that distance.
|
||||
|
||||
Coverage and detail improve progressively; a large selected radius does not mean all its target detail is already resident. Far sampling runs behind the game's frame and tick threads, on half the machine's threads (at most 8) from 8 threads up and one below that; it queues its full share of tiles only once the nearby chunks have loaded. On machines with four threads or fewer it samples one tile every 500 ms and meshes nothing while nearby chunks are generated, lit or meshed for the first time; remeshing chunks already drawn doesn't hold it back. The generated detail uses a third of the threads, rounded up, at idle priority. The control status reports target and sampled target tile counts separately (`lod.target_tiles`, `lod.sampled_target_tiles`), and the far columns that take generated detail and how many have it (`lod.detail_target_columns`, `lod.refined_target_columns`). Direct far sampling approximates vertical noise interpolation, fluid boundaries and lighting; surface slopes and column extensions, small features and partial block shapes can differ. Unexplored distant structures are not synthesized by the direct path, and native decoration order can differ from subsequent gameplay generation. Horizon-supported data-driven noise and tree configurations can be reused; this does not execute arbitrary Fabric/NeoForge worldgen or structure mods.
|
||||
|
||||
|
||||
+15
-2
@@ -1424,8 +1424,21 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
|
||||
before and after and `dimension` the level they are in now. Heard after the move has reached the player's state and
|
||||
their client (into another level: when that level is entered, before it has loaded around them), so it cannot stop it
|
||||
(NeoForge's `EntityTeleportEvent`). The player's state is swapped in, so `native_tell` and the acting-player helpers
|
||||
work. Not heard yet: ender pearls, chorus fruit, a spectator's teleport menu; portals and respawns are not teleports
|
||||
here.
|
||||
work. Ender pearls and chorus fruit have their own hooks below; not heard yet: a spectator's teleport menu; portals
|
||||
and respawns are not teleports here.
|
||||
- **`native_ender_pearl_teleport(player, name, pearl, from, target, damage, cancel)`**: a server player's ender pearl
|
||||
landed and is about to teleport them (NeoForge's `EntityTeleportEvent.EnderPearl`, `ThrownEnderpearl.onHit`).
|
||||
`pearl` is the pearl's entity id, `from` their feet, `target` where the pearl started its last tick and `damage` the
|
||||
fall damage they then take (5). A mod may move `target` or change `damage` (0 or less: none). Canceling keeps the
|
||||
player where they are: no endermite roll, no teleport, no damage, no sound; the pearl is used up either way. Mobs'
|
||||
pearls are not heard. Bundles need `"hooks": @API:ender_pearl_teleport@`.
|
||||
- **`native_chorus_fruit_teleport(player, name, from, target, cancel)`**: a server player whose `teleport_randomly`
|
||||
consume effect (chorus fruit) is about to try a spot (NeoForge's `EntityTeleportEvent.ChorusFruit`,
|
||||
`TeleportRandomlyConsumeEffect.apply`): once per attempt, up to 16, after they stopped riding. A mod may move
|
||||
`target` (its y is kept inside the level's height); the game still looks down from it for ground to land on, inside
|
||||
the border. Canceling ends the effect: no
|
||||
teleport and no more attempts. Foxes eating chorus fruit are not heard. Bundles need
|
||||
`"hooks": @API:chorus_fruit_teleport@`.
|
||||
- **`native_player_commands_sent(player, name, names)`**: the command tree a player is sent, when they join and when
|
||||
their level changes (`/op`, `/deop`, the LAN settings). `names` are the root commands their client would get, vanilla's
|
||||
they may use and the mods'; remove a name and that client's tree lacks it, so its chat box neither completes nor
|
||||
|
||||
@@ -445,6 +445,8 @@ const NOT_COPIED: &[&str] = &[
|
||||
"telemetry",
|
||||
"mod-browser",
|
||||
".horizon-cache",
|
||||
// Far terrain's copies of what servers sent, rebuilt as the instance plays.
|
||||
"server-terrain",
|
||||
];
|
||||
|
||||
/// Files of the player, not of one instance, that a new instance starts with: the keys and video
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "chorus_fruit_teleport",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_chorus_fruit_teleport",
|
||||
"neoforge": "EntityTeleportEvent.ChorusFruit",
|
||||
"side": "server",
|
||||
"cancellable": true,
|
||||
"mod_api": {
|
||||
"contract": "cancel",
|
||||
"out": "cancel"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rust_summary": "a server player who ate chorus fruit is about to try a random spot (`native_chorus_fruit_teleport`).",
|
||||
"doc_summary": "a server player's `teleport_randomly` consume effect (chorus fruit) is about to try a spot: `native_chorus_fruit_teleport(player, name, from, target, cancel)`, once per attempt (up to 16). A mod may move `target`; the game still looks down from it for ground to land on. `cancel` ends the effect with no teleport and no more attempts. Foxes are not heard. Bundles need \"hooks\": @API:chorus_fruit_teleport@."
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "ender_pearl_teleport",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_ender_pearl_teleport",
|
||||
"neoforge": "EntityTeleportEvent.EnderPearl",
|
||||
"side": "server",
|
||||
"cancellable": true,
|
||||
"mod_api": {
|
||||
"contract": "cancel",
|
||||
"out": "cancel"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rust_summary": "a server player's ender pearl is about to teleport them (`native_ender_pearl_teleport`).",
|
||||
"doc_summary": "a server player's ender pearl landed: `native_ender_pearl_teleport(player, name, pearl, from, target, damage, cancel)` runs before the endermite roll and the teleport. A mod may move `target` or change the fall `damage` (5; 0 or less is none); `cancel` keeps the player where they are, without the endermite roll, damage or sound, and the pearl is used up either way. Bundles need \"hooks\": @API:ender_pearl_teleport@."
|
||||
}
|
||||
@@ -40,27 +40,27 @@ FaceInfo.Extent.select skip:data corner selection; Horizon's unit-cube corner t
|
||||
FaceInfo.VertexInfo.select skip:data corner selection; Horizon's unit-cube corner table PORTAL_FACES in block_entity_render.rs has the same order
|
||||
FirstPersonHandsAndItemsRenderer.calculateMapTilt inline crates/horizon-client/src/game/maps.rs::Game::two_handed_map_geometry tilt computed inline with the calculateMapTilt comment
|
||||
FirstPersonHandsAndItemsRenderer.renderPlayerHand port crates/horizon-client/src/game.rs::Game::hand_geometry empty main hand's arm model with the player skin
|
||||
FirstPersonHandsAndItemsRenderer.renderMapHand missing player: holding a map in both hands, neither arm is drawn behind it (vanilla draws both arms)
|
||||
FirstPersonHandsAndItemsRenderer.renderMapHand port crates/horizon-client/src/first_person.rs::map_arm_poses (MapHold::TwoHanded) both arms behind a two-handed map, at the jar's rotations and offsets
|
||||
FirstPersonHandsAndItemsRenderer.renderOneHandedMap port crates/horizon-client/src/first_person.rs::one_handed_map_pose gap: player: the arm holding a map is not drawn; the map hand branch skips the arm
|
||||
FirstPersonHandsAndItemsRenderer.renderTwoHandedMap port crates/horizon-client/src/game/maps.rs::Game::two_handed_map_geometry gap: player: the arms that hold a two-handed map are not drawn
|
||||
FirstPersonHandsAndItemsRenderer.renderMap port crates/horizon-client/src/game/maps.rs::Game::held_map map background with its 7-pixel border, then the map contents
|
||||
FirstPersonHandsAndItemsRenderer.applyEatTransform port crates/horizon-client/src/first_person.rs::item_pose ItemPose::Eat: bob and jiggle while eating or drinking
|
||||
FirstPersonHandsAndItemsRenderer.applyBrushTransform missing player: a brush in use is held still; it does not sweep back and forth while brushing
|
||||
FirstPersonHandsAndItemsRenderer.applyBrushTransform port crates/horizon-client/src/first_person.rs::brush_transform the brush sweep while brushing, term for term
|
||||
FirstPersonHandsAndItemsRenderer.applyItemArmAttackTransform port crates/horizon-client/src/first_person.rs::item_pose attack swing rotation in the Swing pose
|
||||
FirstPersonHandsAndItemsRenderer.applyItemArmTransform port crates/horizon-client/src/first_person.rs::item_pose base item-in-arm translation
|
||||
FirstPersonHandsAndItemsRenderer.submitHandsWithItems port crates/horizon-client/src/game.rs::Game::hand_geometry sway, both hands, empty-hand arm and item pose
|
||||
FirstPersonHandsAndItemsRenderer.swingArm port crates/horizon-client/src/first_person.rs::item_pose attack swing translation and arm rotation
|
||||
GameRenderer.<init> port crates/horizon-client-render/src/gfx.rs::Renderer::new lightmap, hand target, debug crosshair and screen effects are built here
|
||||
GameRenderer.close skip:other GPU resource teardown; wgpu drops the renderer's objects
|
||||
GameRenderer.checkEntityPostEffect missing player: spectating a creeper, spider or enderman does not put their overlay shader on the screen
|
||||
GameRenderer.checkEntityPostEffect port crates/horizon-client/src/game/remote_camera.rs::entity_post_effect spectating maps creeper to minecraft:creeper, spider and cave spider to minecraft:spider, enderman to minecraft:invert, and a first-person camera clears it
|
||||
GameRenderer.processBlurEffect port crates/horizon-client-render/src/menu_blur.rs::MenuBlur::apply menu background blur pass
|
||||
GameRenderer.preloadUiShader skip:other GPU pipeline precompile at load; Horizon builds its pipelines in Renderer::new
|
||||
GameRenderer.update port crates/horizon-client-render/src/camera.rs::AvatarBob::update_bob gap: minor: the requested post-effect list (from /postEffect) is not built
|
||||
GameRenderer.extract skip:other render-state copy for the vanilla render thread; Horizon renders from game state
|
||||
GameRenderer.preparePostEffects port crates/horizon-client-render/src/gfx.rs::post_effects (post_chain::PostEffects, built at gfx.rs:8424) vanilla post chains are made on first use from the request the game builds
|
||||
GameRenderer.applyPostEffects port crates/horizon-client-render/src/gfx.rs::post_effects the same chain runs after the world pass
|
||||
GameRenderer.tryTakeScreenshotIfNeeded missing player: a new singleplayer world gets no icon.png, so the world list shows no preview picture
|
||||
GameRenderer.takeAutoScreenshot missing player: no icon.png is written from the first rendered frame; the world list keeps a blank picture
|
||||
GameRenderer.tryTakeScreenshotIfNeeded port crates/horizon-client/src/game.rs::tick_world_icon (called at game.rs:17852) a local world without an icon requests a 64x64 ScreenshotRequest for saves/<folder>/icon.png once the streamer, uploads and backlog are idle and more than 10 sections are drawn
|
||||
GameRenderer.takeAutoScreenshot inline crates/horizon-client/src/game.rs::tick_world_icon the same retry, once a second until the file exists; the path comes from game/integrated.rs:485
|
||||
GameRenderer.integrate3DHudDepth inline crates/horizon-client-render/src/gfx.rs::post_effects runs with a post chain applied, which is vanilla's consistentDepthRequired condition
|
||||
GameRenderer.extractWindow skip:other render-state copy of the window size; Horizon reads the window directly
|
||||
GameRenderer.extractOptions skip:other render-state copy of options; Horizon reads settings directly
|
||||
@@ -100,7 +100,7 @@ LevelRenderer.invalidateCompiledGeometry port crates/horizon-client-render/src/g
|
||||
LevelRenderer.scheduleTranslucentSectionResort port crates/horizon-client-render/src/gfx/translucency.rs::SortState::update resort scheduled when the camera point of view changes
|
||||
LevelRenderer.scheduleResort port crates/horizon-client-render/src/gfx/translucency.rs::SortState::update per-section resort gated by point-of-view change and translucent geometry
|
||||
LevelRenderer.resetLevelRenderData port crates/horizon-client-render/src/gfx.rs::Renderer::clear_sections section data released when a level is left
|
||||
LevelRenderer.hasRenderedAllSections missing minor: only the world icon screenshot asks this, and that screenshot is missing
|
||||
LevelRenderer.hasRenderedAllSections inline crates/horizon-client/src/game.rs::tick_world_icon renderer.drawn_sections > 10: the icon waits on the same count vanilla's hasRenderedAllSections asks for
|
||||
LevelRenderer.isSectionCompiledAndVisible port crates/horizon-client-render/src/gfx/resident.rs::Resident::contains gap: minor: no section fade-in, so the 0.3 visibility threshold is not checked
|
||||
LevelRenderer.finalizeGizmoCollection port crates/horizon-client/src/game/debug_keys.rs::Game::debug_gizmos debug gizmos collected for the frame
|
||||
LevelTargetBundle.replace skip:other render-target bundle for post chains; Horizon has no frame graph
|
||||
|
||||
|
@@ -8,7 +8,7 @@ Rect2i.contains skip:ui GUI hit test on a rectangle; Horizon's widgets hit-test
|
||||
RenderPipelines.register skip:data pipeline registry; Horizon creates its pipelines in gfx.rs
|
||||
RunningTrimmedMean.registerValueAndGetMean skip:debug frame-time smoothing helper; no vanilla caller in this source tree
|
||||
ScreenEffectRenderer.submit port crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build gap: minor: the item activation is not built here (see renderItemActivationAnimation)
|
||||
ScreenEffectRenderer.renderItemActivationAnimation missing player: when a totem of undying saves the player, the totem does not pop up in front of the screen; Horizon only plays the totem particles and sound (game/totem.rs totem_event)
|
||||
ScreenEffectRenderer.renderItemActivationAnimation port crates/horizon-client/src/game.rs::itemActivation + tick_totem (game.rs:2205, 7139) the totem flying up to the screen when it saves the player
|
||||
ScreenEffectRenderer.submitWater inline crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build underwater overlay branch (misc/underwater)
|
||||
ScreenEffectRenderer.submitFire inline crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build first-person fire overlay branch (block/fire_1, two quads)
|
||||
ScreenEffectRenderer.buildFireQuad inline crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build the two fire quads' corners and offsets
|
||||
|
||||
|
@@ -25,7 +25,7 @@ ParticleDescription.fromJson port crates/horizon-client-render/src/particles.rs:
|
||||
ParticleEngine.makeParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::make gap: minor: VibrationSignal options return None here; ParticleEngine::vibration creates them
|
||||
ParticleEngine.createParticleGroup skip:other no render-type groups: ParticleEngine::render draws every particle in one pass, and the ITEM_PICKUP group is tick_pickups
|
||||
ParticleEngine.countParticles skip:debug
|
||||
ParticleEngine.hasSpaceInParticleLimit missing minor: no per-type particle cap; vanilla limits live SPORE_BLOSSOM air particles to 1000
|
||||
ParticleEngine.hasSpaceInParticleLimit port crates/horizon-client-render/src/particles.rs (test spore_blossom_air_stops_at_its_particle_limit) per-type particle caps, pinned by that test
|
||||
ParticleEngine.clearParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::clear clears emitters, live and pending particles; no limit counts to clear
|
||||
ParticleGroup.tickParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick ticks live particles and drops the removed ones
|
||||
ParticleGroup.tickParticle skip:debug crash-report wrapper around particle.tick
|
||||
|
||||
|
@@ -99,7 +99,7 @@ ServerPlayer.respawnEntityOnShoulder port crates/horizon-client/src/game/shoulde
|
||||
ServerPlayer.trackStartFallingPosition port crates/horizon-client/src/game/advancements.rs::Game::tick_marks the fall start and the lava ride feed `DistanceTrigger` (`fall_from_height`, `ride_entity_in_lava`)
|
||||
ServerPlayer.trackEnteredOrExitedLavaOnVehicle port crates/horizon-client/src/game/advancements.rs::Game::tick_marks the fall start and the lava ride feed `DistanceTrigger` (`fall_from_height`, `ride_entity_in_lava`)
|
||||
ServerPlayer.updateScoreForCriteria port crates/horizon-client/src/game/scoreboard.rs::Game::tick_scoreboard health, food, air, armor, xp and level objectives
|
||||
ServerPlayer.tellNeutralMobsThatIDied missing player: wolves, bees, piglins and other mobs that were angry at the player stay angry after the player dies and respawns (vanilla forgives them with forgive_dead_players)
|
||||
ServerPlayer.tellNeutralMobsThatIDied port crates/horizon-client/src/game/creatures.rs::Game::tell_neutral_mobs_player_died mobs within 32 blocks across and 10 up and down forgive a dead player when the rule is on
|
||||
ServerPlayer.handleTeamKill inline crates/horizon-client/src/game/scoreboard.rs::Game::score_player_killed teamkill and killedByTeam objectives
|
||||
ServerPlayer.hurtServer port crates/horizon-client/src/game/lan_pvp.rs::Game::pvp_land pvp rule and team friendly fire gate the damage
|
||||
ServerPlayer.addPostEffect port crates/horizon-client/src/game/commands/extra.rs::Game::cmd_posteffect gap: minor: one list shared by every target, nothing is sent to guests (an empty list at join) and no post shader is drawn
|
||||
@@ -125,7 +125,7 @@ ServerPlayer.magicCrit port crates/horizon-client/src/lan/containers.rs::Hub::cr
|
||||
ServerPlayer.onUpdateAbilities inline crates/horizon-client/src/lan/hub.rs::Hub::set_game_mode abilities go out with a game mode change; there is no other way abilities change
|
||||
ServerPlayer.createCommandSourceStack inline crates/horizon-client/src/game/lan_actions.rs::Game::run_guest_command the guest is swapped in as the acting player and its permission level applied
|
||||
ServerPlayer.sendSystemMessage port crates/horizon-client/src/lan/hub.rs::Hub::system_message
|
||||
ServerPlayer.sendBuildLimitMessage missing player: placing a block at the build limit fails silently; the red build.tooHigh / build.tooLow overlay never shows
|
||||
ServerPlayer.sendBuildLimitMessage port crates/horizon-client/src/game/lan_actions.rs::Game::build_limit_message the build.tooHigh and build.tooLow overlay at the world height limits
|
||||
ServerPlayer.getIpAddress skip:server for ban-ip and the dedicated server log
|
||||
ServerPlayer.updateOptions port crates/horizon-client/src/lan/hub.rs::Hub::process_inbox the ClientInformation arm: view distance, main hand, model parts and chat visibility are kept
|
||||
ServerPlayer.clientInformation skip:java getter
|
||||
|
||||
|
@@ -39,7 +39,7 @@ AbstractMinecart.animateHurt port crates/horizon-client/src/game/minecarts.rs::G
|
||||
AbstractMinecart.getCurrentBlockPosOrRailBelow port crates/horizon-client/src/game/minecarts.rs::Game::rail_block the rail one block below counts; the experimental y offset is not used
|
||||
AbstractMinecart.comeOffTrack port crates/horizon-client/src/game/minecarts.rs::Game::come_off_track clamps speed, halves it on the ground, moves and drags in the air
|
||||
AbstractMinecart.move port crates/horizon-client/src/game/minecarts.rs::Game::move_cart collision movement only; gap: minor: no push or pickup during the move, which push_carts and the cart tick do instead
|
||||
AbstractMinecart.applyEffectsFromBlocks inline crates/horizon-client/src/game/minecarts.rs::Game::cart_lava_tick lava is applied there; gap: minor: other block effects on a cart (cobweb, berry bush, bubble column) are not applied
|
||||
AbstractMinecart.applyEffectsFromBlocks inline crates/horizon-client/src/game/minecarts.rs::Game::cart_lava_tick lava is applied there; gap: minor: other block effects on a cart (cobweb, berry bush) are not applied; under minecart_improvements, NewMinecartBehavior.moveAlongTrack applies block effects twice per step, and Horizon keeps the old order
|
||||
AbstractMinecart.getRedstoneDirection skip:other used only by the experimental NewMinecartBehavior; Horizon runs the old track behaviour
|
||||
AbstractMinecart.applyNaturalSlowdown port crates/horizon-client/src/game/minecarts.rs::Game::natural_slowdown slowdown per ride state, the furnace push and water
|
||||
AbstractMinecart.readAdditionalSaveData port crates/horizon-client/src/game/minecarts.rs::Minecart::from_nbt flipped and HasTicked read back; gap: minor: a custom display block and offset are not kept
|
||||
|
||||
|
@@ -7,7 +7,7 @@ MenuType.register skip:data menu type registry bootstrap
|
||||
MerchantContainer.isEmpty skip:java container emptiness helper; Horizon checks the stacks it needs directly
|
||||
MerchantContainer.setItem inline crates/horizon-client/src/menu.rs::Menu::slot_changed the store is the generic put; the payment update is merchant_update, run from slot_changed for every merchant slot
|
||||
MerchantMenu.quickMoveStack inline crates/horizon-client/src/menu.rs::Menu::quick_move the Kind::Merchant arm; a result shift-click also queues the trade sound event
|
||||
MerchantMenu.playTradeSound missing player: a shift-click trade should play the villager yes sound; the menu queues Event::TradeSound and game/trading.rs drops it with an empty arm
|
||||
MerchantMenu.playTradeSound port crates/horizon-client/src/game/trading.rs::Game::merchant_apply the TradeSound event plays entity.villager.yes or the wandering trader yes
|
||||
MerchantResultSlot.remove port crates/horizon-client/src/menu.rs::Menu::remove gap: minor: the removeCount tally that feeds the crafted statistic is not kept
|
||||
MerchantResultSlot.mayPickup port crates/horizon-client/src/menu.rs::Menu::may_pickup the Role::MerchantResult arm
|
||||
ResultSlot.remove port crates/horizon-client/src/menu.rs::Menu::remove gap: minor: the removeCount tally for the crafted statistic is not kept
|
||||
@@ -115,7 +115,7 @@ ItemStack.applyComponentsAndValidate missing unsure: a validated component patc
|
||||
ItemStack.getStyledHoverName port crates/horizon-client/src/game/stations.rs::Game::stack_hover_name gap: minor: plain text; the rarity colour and italic style are not applied here
|
||||
ItemStack.addToTooltip port crates/horizon-client/src/item_tooltip.rs::lines
|
||||
ItemStack.addUnitComponentToTooltip inline crates/horizon-client/src/game/stations.rs::Game::stack_tooltip_lines unit lines such as unbreakable, gated by the tooltip display
|
||||
ItemStack.canPlaceOnBlockInAdventureMode missing player: in adventure mode a block can be placed only when the held item's can_place_on list allows it; Horizon does not read that component
|
||||
ItemStack.canPlaceOnBlockInAdventureMode port crates/horizon-client/src/game/game_mode.rs::Game::can_place_on_in_adventure the can_place_on predicates gate a placement in adventure mode
|
||||
ItemStack.canBreakBlockInAdventureMode missing player: in adventure mode a block can be broken only when the held item's can_break list allows it; Horizon does not read that component
|
||||
ItemStack.getDamageSource missing unsure: a stack's damage_type component is not read when a weapon hits; no Horizon reader found
|
||||
ItemStackTemplate.fromNonEmptyStack port crates/horizon-items/src/item_data.rs::template_tag stack to template tag
|
||||
@@ -141,7 +141,7 @@ MapItem.lockMap port crates/horizon-client/src/menu.rs::Menu::map_crafted the lo
|
||||
MapItem.useOn missing player: using a map on a banner block should toggle that banner's marker on the map; Horizon has no banner toggle
|
||||
MobBucketItem.emptyContents port crates/horizon-world/src/level/buckets.rs::Sim::bucket_empty the MobWater and Nothing contents
|
||||
PotionItem.getDefaultInstance skip:data default stack data
|
||||
PotionItem.useOn missing player: a water bottle used on a block that turns to mud should make mud; no such conversion was found in potions.rs
|
||||
PotionItem.useOn port crates/horizon-client/src/game/potions.rs::Game::use_water_bottle_on_block a water bottle poured on #convertible_to_mud makes mud and empties the bottle
|
||||
PotionItem.getName port crates/horizon-client/src/game/stations.rs::Game::stack_hover_name potion name from the contents
|
||||
ProjectileItem.DispenseConfig.Builder.build inline crates/horizon-client/src/game/dispenser.rs::Game::dispense_projectile per-item dispense config
|
||||
ProjectileWeaponItem.getHeldProjectile port crates/horizon-client/src/game/crossbow.rs::Game::projectile_slot offhand first, then the held slot
|
||||
|
||||
|
@@ -25,7 +25,7 @@ CraftingTableBlock.useWithoutItem port crates/horizon-world/src/level/interact.r
|
||||
CreakingHeartBlock.getTicker port crates/horizon-world/src/level/creaking.rs::Sim::heart_tick scheduled heart tick; the uprooted check is done inside heart_update_state
|
||||
CreakingHeartBlock.animateTick port crates/horizon-client/src/animate_tick.rs::step ticked in #3516; `CreakingHeartBlock.animateTick` is cited at crates/horizon-client/src/animate_tick.rs:104
|
||||
CreakingHeartBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class CreakingHeart arm schedules the 1-tick heart update then the default shape
|
||||
CreakingHeartBlock.isSurroundedByLogs missing player: the six-side pale oak log check only gates the idle creak sound in animateTick
|
||||
CreakingHeartBlock.isSurroundedByLogs port crates/horizon-client/src/mobs/creaking_heart.rs::has_required_logs the six sides must be pale oak logs; a heart without them spawns no creaking
|
||||
CreakingHeartBlock.createBlockStateDefinition skip:data axis, creaking_heart_state and natural properties come from the block state tables
|
||||
CreakingHeartBlock.tryAwardExperience inline crates/horizon-client/src/game/creaking.rs::Game::creaking_heart_broken natural heart drops 20-24 xp orbs when broken by a player outside creative and spectator
|
||||
CreakingHeartBlock.getAnalogOutputSignal port crates/horizon-client/src/mobs/creaking_heart.rs::output_signal gap: minor: distance-based signal, no explicit uprooted check; a heart with no creaking or protector gives 0
|
||||
|
||||
|
@@ -136,7 +136,7 @@ LocalPlayer.shouldStopRunSprinting port crates/horizon-client/src/sprint.rs::Con
|
||||
LocalPlayer.shouldStopSwimSprinting port crates/horizon-client/src/sprint.rs::Conditions::should_stop
|
||||
LocalPlayer.tickDeath missing minor: unsure: the dead local player is not removed after 20 ticks; the death screen flow covers it
|
||||
LocalPlayer.rideTick missing minor: unsure: no code found that feeds movement keys into a boat's paddling
|
||||
LocalPlayer.isHorizontalCollisionMinor missing player: sprinting into a wall at an angle stops the sprint (motion.rs minor_horizontal_collision is a stub returning false)
|
||||
LocalPlayer.isHorizontalCollisionMinor port crates/horizon-client/src/motion.rs::EntityMoveWorld::minor_horizontal_collision the sprint keeps when a move hits a wall at under 8 degrees
|
||||
LocalPlayer.isMoving inline crates/horizon-client/src/auto_jump.rs::can_auto_jump
|
||||
LocalPlayer.isSprintingPossible port crates/horizon-client/src/sprint.rs::Conditions::possible
|
||||
LocalPlayer.canStartSprinting port crates/horizon-client/src/sprint.rs::Conditions::can_start gap: player: slow_item is never set, so eating does not block starting a sprint
|
||||
|
||||
|
@@ -67,7 +67,7 @@ SoundBufferLibrary.enumerate port crates/horizon-client-render/src/audio.rs::Aud
|
||||
SoundBufferLibrary.DebugOutput.Counter.accountBuffer inline crates/horizon-client-render/src/audio.rs::Audio::census counts buffers and sizes
|
||||
SoundEngine.reload port crates/horizon-client-render/src/audio.rs::Audio::reload stops sounds and reads the new resource root
|
||||
SoundEngine.loadLibrary port crates/horizon-client-render/src/audio.rs::Audio::with_output opens the output device
|
||||
SoundEngine.shouldChangeDevice missing player: when the output device is lost or the system default changes, vanilla reopens sound output; Horizon's Audio has no device watch
|
||||
SoundEngine.shouldChangeDevice port crates/horizon-client-render/src/audio.rs: devices_seen / last_seen_devices the output device list is watched and the sink reopened when it changes
|
||||
SoundEngine.notifyListeners port crates/horizon-client-render/src/sound_engine.rs::SoundEngine::notify subtitle and listener notification with range
|
||||
SoundEngine.updateSource port crates/horizon-client-render/src/audio.rs::Audio::set_listener listener position and orientation
|
||||
SoundEngineExecutor.createThread skip:other sound thread creation; Horizon's audio runs on the cpal callback
|
||||
@@ -78,7 +78,7 @@ SoundManager.apply port crates/horizon-client-render/src/audio.rs::Audio::reload
|
||||
SoundManager.validateSoundResource inline crates/horizon-client-render/src/sound_events.rs::SoundRegistry::add_file file existence check through the exists closure
|
||||
SoundManager.Preparations.handleRegistration port crates/horizon-client-render/src/sound_events.rs::SoundRegistry::add_file replace flag and sound entries
|
||||
SoundManager.Preparations.apply inline crates/horizon-client-render/src/sound_events.rs::SoundRegistry::load registry replaced with the prepared one
|
||||
SoundPreviewHandler.preview port crates/horizon-client-render/src/sound_preview.rs::SoundPreview::preview
|
||||
SoundPreviewHandler.preview port crates/horizon-client-render/src/audio.rs::preview the pack screen's sound preview
|
||||
SoundPreviewHandler.stopOtherCategoryPreview port crates/horizon-client-render/src/sound_preview.rs::SoundPreview::stop_other_category_preview
|
||||
WeighedSoundEvents.preloadIfRequired skip:java preload is a cache; Horizon decodes on demand
|
||||
CraftPlanksTutorialStep.tick missing player: a new survival world shows no craft-planks tutorial toast; Horizon has no tutorial steps (tutorialStep is only stored in options)
|
||||
|
||||
|
@@ -4,16 +4,16 @@ LinkFileSystem skip:other link filesystem and its getPath; Horizon builds overl
|
||||
LinkFileSystem.Builder skip:other builder of the link filesystem tree; no equivalent, see packs.rs::build_overlay
|
||||
PackFormat.fullCodec skip:java codec for supported_formats; packs.rs::compatible reads the same array
|
||||
PackFormat.validateHolderList inline crates/horizon-client-render/src/packs.rs::overlays_of gap: minor: a broken overlay entry is dropped alone; vanilla rejects the whole overlays list
|
||||
PackFormat.lastPreMinorVersion missing minor: the 64 and 81 cut-offs feed only the format validators, which Horizon lacks
|
||||
PackFormat.lastPreMinorVersion inline crates/horizon-client-render/src/packs.rs the cut-off the old-format validator compares against
|
||||
PackFormat.packCodec skip:java codec wiring; the validation it runs is missing (IntermediaryFormat.validate)
|
||||
PackFormat.of skip:java constructor; Horizon keeps (major, minor) tuples (packs.rs::RESOURCE_FORMAT)
|
||||
PackFormat.compareTo skip:java ordering; Horizon compares the (major, minor) tuples directly (packs.rs::compatibility)
|
||||
PackFormat.toString skip:debug text form of a format, used in validation messages only
|
||||
PackFormat.IntermediaryFormat.fromRange skip:java encoder back to pack.mcmeta; Horizon never writes pack.mcmeta
|
||||
PackFormat.IntermediaryFormat.effectiveMinMajorVersion missing minor: only the overlay validator uses it
|
||||
PackFormat.IntermediaryFormat.validate missing player: vanilla refuses a pack.mcmeta with incomplete format fields (min_format without max_format, or pack_format outside the range); Horizon takes the ranges as written
|
||||
PackFormat.IntermediaryFormat.validateNewFormat missing player: vanilla refuses min_format above max_format and a missing supported_formats or pack_format where an older game needs them; Horizon does not check
|
||||
PackFormat.IntermediaryFormat.validateOldFormat missing player: vanilla refuses a supported_formats range above the last pre-minor version, or one without pack_format; Horizon does not check
|
||||
PackFormat.IntermediaryFormat.effectiveMinMajorVersion inline crates/horizon-client-render/src/packs.rs::validate_pack_format_for_range feeds the range check
|
||||
PackFormat.IntermediaryFormat.validate port crates/horizon-client-render/src/packs.rs::validate_pack_range pack.mcmeta format fields are checked against the supported range
|
||||
PackFormat.IntermediaryFormat.validateNewFormat port crates/horizon-client-render/src/packs.rs::validate_new_format min above max and a missing supported_formats / pack_format are refused
|
||||
PackFormat.IntermediaryFormat.validateOldFormat port crates/horizon-client-render/src/packs.rs::validate_old_format an old range above the last pre-minor version is refused
|
||||
PackFormat.IntermediaryFormat.validatePackFormatForRange missing player: vanilla refuses a pack_format outside its min to max range, or a multi-version pack below format 15; Horizon does not check
|
||||
PackMetadataSection.codecForPackType skip:java codec for the pack section; packs.rs::pack reads the same fields
|
||||
PackMetadataSection.forPackType skip:java picks the section type per pack type; Horizon reads one pack section
|
||||
|
||||
|
@@ -24,8 +24,8 @@ Util.getRegisteredName skip:debug registry key text for logs and debug output (
|
||||
Util.allOf skip:java predicate combinator
|
||||
Util.anyOf skip:java predicate combinator
|
||||
Util.growByHalf skip:java array growth; Rust Vec grows itself
|
||||
Util.findNextInIterable missing player: an op with a debug stick in creative right-clicks and no block state property cycles to the next value; Horizon has no DebugStickItem behaviour (only datafix and item model names)
|
||||
Util.findPreviousInIterable missing player: the same debug stick sneak-click does not cycle a property backwards; Horizon has no DebugStickItem behaviour
|
||||
Util.findNextInIterable port crates/horizon-client/src/game/debug_stick.rs::Game::debug_stick_interact the debug stick cycles a property forward, and backward while sneaking
|
||||
Util.findPreviousInIterable port crates/horizon-client/src/game/debug_stick.rs::Game::debug_stick_interact same function: relative_index walks the value list backward on sneak
|
||||
Util.makeEnumMap skip:java EnumMap builder
|
||||
Util.mapValues skip:java map stream helper
|
||||
Util.sequence skip:java future combinator
|
||||
|
||||
|
@@ -6,8 +6,8 @@
|
||||
A real Horizon client (horizon-ctl, offscreen) with LOD Distance 32 joins a dedicated server:
|
||||
- `horizon-lod-distance=16`: the server samples the client's far terrain (`status`'s lod.source is "server"), no
|
||||
farther than 16 chunks (effective_distance 16), and far tiles are drawn (active_tiles > 0);
|
||||
- no `horizon-lod-distance` (off by default): the server offers none and the client has no far terrain (source
|
||||
"none").
|
||||
- no `horizon-lod-distance` (off by default): the server offers none, so the client draws only the chunks it was
|
||||
sent (source "server-terrain", `server_terrain.rs`), nothing the server sampled.
|
||||
|
||||
Each server and game runs in fresh folders with fresh HORIZON_HOME and XDG folders (lab.game_env), on a free
|
||||
port. Build first:
|
||||
@@ -94,9 +94,9 @@ def check(bins, scratch, out):
|
||||
finally:
|
||||
s.stop()
|
||||
print(f'off: {lod}')
|
||||
if lod['source'] != 'none' or lod['active_tiles'] != 0:
|
||||
raise Failed(f'off: the client has far terrain from a server that offers none: {lod}')
|
||||
print('off: no far terrain')
|
||||
if lod['source'] != 'server-terrain':
|
||||
raise Failed(f'off: expected only the chunks the server sent (server-terrain): {lod}')
|
||||
print('off: only the chunks the server sent')
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Reference in new issue
Block a user