mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge branch 'main' into feat/quit-shortcuts
This commit is contained in:
240 files changed
+12043
-2433
No files matched your search
Generated
+27
@@ -561,6 +561,12 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cty"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -1412,6 +1418,8 @@ name = "horizon-threads"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libmimalloc-sys",
|
||||
"mimalloc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1787,6 +1795,16 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cty",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.25"
|
||||
@@ -1878,6 +1896,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -166,7 +166,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
|
||||
|
||||
### Known issues
|
||||
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct Registries {
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
|
||||
/// each once.
|
||||
pub custom_completions: std::collections::BTreeSet<String>,
|
||||
/// Scoreboard objective names, team names and tracked score holders.
|
||||
pub objectives: Vec<String>,
|
||||
pub teams: Vec<String>,
|
||||
@@ -87,7 +91,55 @@ pub struct RegistryIds {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
const EMPTY: &[String] = &[];
|
||||
/// ClientboundCustomChatCompletionsPacket.Action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CustomCompletionAction {
|
||||
Add,
|
||||
Remove,
|
||||
Set,
|
||||
}
|
||||
impl CustomCompletionAction {
|
||||
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
|
||||
/// so an unknown id is `ADD`.
|
||||
pub fn from_id(id: i32) -> Self {
|
||||
match id {
|
||||
1 => Self::Remove,
|
||||
2 => Self::Set,
|
||||
_ => Self::Add,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
|
||||
/// removes or replaces the extra chat completions.
|
||||
pub fn modify_custom_completions(
|
||||
set: &mut std::collections::BTreeSet<String>,
|
||||
action: CustomCompletionAction,
|
||||
entries: &[String],
|
||||
) {
|
||||
match action {
|
||||
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
|
||||
CustomCompletionAction::Remove => {
|
||||
for e in entries {
|
||||
set.remove(e);
|
||||
}
|
||||
}
|
||||
CustomCompletionAction::Set => {
|
||||
set.clear();
|
||||
set.extend(entries.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Registries {
|
||||
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
|
||||
/// command) completes, the online players' names with the server's custom
|
||||
/// completions, each once.
|
||||
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
|
||||
self.players
|
||||
.iter()
|
||||
.chain(&self.custom_completions)
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
/// Fill what the game did not build itself from vanilla's exported
|
||||
/// registries (`command-registries.json`, written by
|
||||
/// `VanillaCommandParseOracle registries`): built-in registries such as
|
||||
|
||||
@@ -192,8 +192,9 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the models and
|
||||
/// the sprites' layers, without the textures' pixels.
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the item models
|
||||
/// and the sprites' layers, without the block models or the textures'
|
||||
/// pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
@@ -202,14 +203,19 @@ impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
Self::load_with(root, assets, true)
|
||||
}
|
||||
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
|
||||
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
|
||||
// A server draws no block: its models (about 24 MB baked) stay empty.
|
||||
let models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
crate::models::PreparedModels::default()
|
||||
};
|
||||
let model_library = parsed.into_item_library();
|
||||
// Fluids are drawn by the fluid renderer, not block models; breaking
|
||||
// progress uses the destroy-stage overlays.
|
||||
@@ -226,7 +232,7 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if pixels {
|
||||
if draws {
|
||||
sprites.finish();
|
||||
} else {
|
||||
sprites.finish_without_pixels();
|
||||
|
||||
@@ -165,6 +165,8 @@ pub struct Audio {
|
||||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||||
/// any), with or without an audio device, as `heard`.
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -634,6 +636,7 @@ impl Audio {
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -817,6 +820,13 @@ impl Audio {
|
||||
false,
|
||||
);
|
||||
}
|
||||
/// `SoundPreviewHandler.preview`: a category volume slider moved to
|
||||
/// `volume` on the title screen's options.
|
||||
pub fn preview(&mut self, source: usize, volume: f32) {
|
||||
let mut preview = std::mem::take(&mut self.preview);
|
||||
preview.preview(self, source, volume);
|
||||
self.preview = preview;
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
|
||||
@@ -52,20 +52,20 @@ impl AuthSession {
|
||||
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
|
||||
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
|
||||
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
|
||||
let name = field("name").ok_or("the session file has no name")?;
|
||||
let name = field("name").ok_or("the session has no name")?;
|
||||
if name.is_empty() || name.chars().count() > 16 {
|
||||
return Err("the session file's name is not a valid player name".into());
|
||||
return Err("the session's name is not a valid player name".into());
|
||||
}
|
||||
let uuid = field("uuid")
|
||||
.and_then(parse_uuid)
|
||||
.ok_or("the session file has no valid uuid")?;
|
||||
.ok_or("the session has no valid uuid")?;
|
||||
let access_token = field("access_token")
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or("the session file has no access token")?;
|
||||
.ok_or("the session has no access token")?;
|
||||
if field("user_type").is_some_and(|t| t != "msa") {
|
||||
return Err("the session file is not a Microsoft account session".into());
|
||||
return Err("the session is not a Microsoft account session".into());
|
||||
}
|
||||
Ok(Self {
|
||||
name: name.to_owned(),
|
||||
|
||||
@@ -929,7 +929,9 @@ impl ChatScreen {
|
||||
} else if !command.chars().all(char::is_whitespace) {
|
||||
let partial = &command[..cursor_b];
|
||||
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
|
||||
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the players
|
||||
// and the server's custom chat completions.
|
||||
builder.suggest_matching(c.registries.custom_tab_suggestions());
|
||||
let (range, list) = merge_suggestions(partial, builder.out);
|
||||
self.awaiting = None;
|
||||
self.pending = Some((range.0, list));
|
||||
|
||||
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
assert_eq!(screen.value(), "/coords");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_completes_the_servers_custom_completions_with_the_players() {
|
||||
// CommandSuggestions.updateCommandInfo completes chat from
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
|
||||
// and what ClientboundCustomChatCompletionsPacket added.
|
||||
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let mut registries = Registries {
|
||||
players: vec!["Steve".into()],
|
||||
..Registries::default()
|
||||
};
|
||||
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
|
||||
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
|
||||
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
|
||||
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
|
||||
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
|
||||
assert_eq!(
|
||||
registries
|
||||
.custom_tab_suggestions()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
["#trade", "Notch", "Steve", "Zuri"]
|
||||
);
|
||||
let completed = |registries: &Registries, line: &str| {
|
||||
let dispatcher = crate::command::vanilla::game_dispatcher();
|
||||
let c = Commands {
|
||||
dispatcher,
|
||||
registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
// Chat shows no list by itself: Tab shows it, Tab again uses it.
|
||||
for _ in 0..2 {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
}
|
||||
screen.value().to_owned()
|
||||
};
|
||||
assert_eq!(completed(®istries, "hi no"), "hi Notch");
|
||||
assert_eq!(completed(®istries, "sell #tr"), "sell #trade");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi al");
|
||||
// SET replaces them all; the players stay.
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
|
||||
assert_eq!(completed(®istries, "hi no"), "hi no");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi Alex");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
|
||||
|
||||
@@ -160,6 +160,13 @@ impl ColumnLayout {
|
||||
self.min_y + index as i32 * 16
|
||||
}
|
||||
}
|
||||
/// `true` for each section of `layout`: what callers that keep no
|
||||
/// per-section record (of fluids, say) pass as "every section may have it".
|
||||
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
|
||||
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
|
||||
[true; crate::section_mask::SectionMask::CAPACITY];
|
||||
&ALL[..layout.sections()]
|
||||
}
|
||||
impl Default for ColumnLayout {
|
||||
fn default() -> Self {
|
||||
Self::VANILLA
|
||||
|
||||
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
|
||||
@@ -320,6 +320,10 @@ fn thread_name() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
|
||||
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
|
||||
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
|
||||
|
||||
/// Writes the report and tells where it is; the log gets the error too.
|
||||
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
@@ -343,7 +347,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
match report.save(home) {
|
||||
Ok(path) => {
|
||||
eprintln!("Horizon: crash report saved to {}", path.display());
|
||||
eprintln!("{SAVED_LINE}{}", path.display());
|
||||
Some(path)
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -590,6 +590,10 @@ pub struct Renderer {
|
||||
readback: readback::Readback,
|
||||
pub info: wgpu::AdapterInfo,
|
||||
pub suspended: bool,
|
||||
/// Consecutive frames whose surface acquisition failed validation. The
|
||||
/// first gets the swapchain a fresh configuration; later ones reattach
|
||||
/// the window, as a lost surface does.
|
||||
acquire_validation_failures: u32,
|
||||
pub drawn_sections: usize,
|
||||
/// Last frame's section draws (face ranges and translucent sections),
|
||||
/// and the draw calls the backend issued for them.
|
||||
@@ -792,9 +796,14 @@ fn mip_chain(
|
||||
/// pass is also what passes each draw's first instance to shaders reading
|
||||
/// `instance_index`, and DirectX 12 is the fallback, where working matters
|
||||
/// more than CPU time.
|
||||
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
|
||||
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
|
||||
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
|
||||
/// try at a backend that had no adapter at all.
|
||||
fn instance_descriptor(
|
||||
backends: wgpu::Backends,
|
||||
window: Option<&Arc<Window>>,
|
||||
allow_nonconformant: bool,
|
||||
) -> wgpu::InstanceDescriptor {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
if let Some(window) = window {
|
||||
@@ -807,7 +816,13 @@ fn instance_descriptor(
|
||||
.flags
|
||||
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
|
||||
}
|
||||
descriptor.with_env()
|
||||
let mut descriptor = descriptor.with_env();
|
||||
if allow_nonconformant {
|
||||
descriptor
|
||||
.flags
|
||||
.insert(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER);
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
/// The window's display connection, so the OpenGL backend creates its EGL
|
||||
/// display from the X11 or Wayland connection the window lives on. Without
|
||||
@@ -946,96 +961,147 @@ async fn open_device(
|
||||
if attempt > 0 {
|
||||
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
|
||||
}
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name} surface: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(adapter) => adapter,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
describe_adapter(&adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
if let Err(e) = check_layers(&limits, layers) {
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
|
||||
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
|
||||
// stand-in neither clamps nor drops NaN.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
|
||||
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
|
||||
let driver = format!("{} {}", info.driver, info.driver_info);
|
||||
startup_step(format_args!(
|
||||
"requesting the device (driver {}) with features [{features}] and the adapter's \
|
||||
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
|
||||
match driver.trim() {
|
||||
"" => "unnamed",
|
||||
driver => driver,
|
||||
},
|
||||
limits.max_buffer_size,
|
||||
limits.max_storage_buffer_binding_size,
|
||||
limits.max_texture_array_layers,
|
||||
));
|
||||
let desc = wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
required_features: features,
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let opened = match info.backend {
|
||||
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
|
||||
_ => None,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let opened = None;
|
||||
let opened = match opened {
|
||||
Some(opened) => opened,
|
||||
None => adapter.request_device(&desc).await,
|
||||
};
|
||||
match opened {
|
||||
Ok((device, queue)) => {
|
||||
return Ok(Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
});
|
||||
}
|
||||
Err(e) => failures.push(format!("{name} device: {e}")),
|
||||
match open_with_retry(backends, window, offscreen, layers).await {
|
||||
Ok(opened) => return Ok(opened),
|
||||
Err(e) => failures.push(e),
|
||||
}
|
||||
}
|
||||
Err(format!("{NO_GPU_DRIVER} ({})", failures.join("; ")).into())
|
||||
}
|
||||
/// Why a backend gave no device: no adapter at all, which a second try with the
|
||||
/// adapters wgpu hides can still find, or a failure after an adapter.
|
||||
enum NoDevice {
|
||||
NoAdapter(String),
|
||||
Failed(String),
|
||||
}
|
||||
/// Opens a device on one backend, and on a backend whose adapters wgpu all hid
|
||||
/// for reporting no Vulkan conformance version tries it again with them shown:
|
||||
/// Mesa's hasvk drives Ivy Bridge and Haswell, works, and reports 0.0.0.0, so
|
||||
/// without this the player is told they have no graphics driver while Vulkan
|
||||
/// runs (a `gl not compatible with provided surface` fallback, or nothing, is
|
||||
/// all they get). Returns why the backend gave no device.
|
||||
async fn open_with_retry(
|
||||
backends: wgpu::Backends,
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
layers: u32,
|
||||
) -> std::result::Result<Opened, String> {
|
||||
match open_backend(backends, window, offscreen, false, layers).await {
|
||||
Err(NoDevice::NoAdapter(hidden)) => {
|
||||
log::warn!(
|
||||
"Horizon renderer: {} has no conformant adapter ({hidden}); asking again for the \
|
||||
ones wgpu hides as non-conformant",
|
||||
backends_name(backends)
|
||||
);
|
||||
match open_backend(backends, window, offscreen, true, layers).await {
|
||||
Ok(opened) => {
|
||||
log::warn!(
|
||||
"Horizon renderer: using {} although its driver reports no Vulkan \
|
||||
conformance version",
|
||||
opened.adapter.get_info().name
|
||||
);
|
||||
Ok(opened)
|
||||
}
|
||||
Err(NoDevice::NoAdapter(e)) | Err(NoDevice::Failed(e)) => Err(e),
|
||||
}
|
||||
}
|
||||
Err(NoDevice::Failed(e)) => Err(e),
|
||||
Ok(opened) => Ok(opened),
|
||||
}
|
||||
}
|
||||
/// Opens a device on `backends` alone: its surface, adapter and device, or what
|
||||
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try.
|
||||
async fn open_backend(
|
||||
backends: wgpu::Backends,
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
allow_nonconformant: bool,
|
||||
layers: u32,
|
||||
) -> std::result::Result<Opened, NoDevice> {
|
||||
let name = backends_name(backends);
|
||||
let instance = wgpu::Instance::new(instance_descriptor(
|
||||
backends,
|
||||
Some(window),
|
||||
allow_nonconformant,
|
||||
));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
|
||||
}
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(adapter) => adapter,
|
||||
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
|
||||
};
|
||||
describe_adapter(&adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
if let Err(e) = check_layers(&limits, layers) {
|
||||
return Err(NoDevice::Failed(format!("{name}: {e}")));
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
|
||||
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
|
||||
// stand-in neither clamps nor drops NaN.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
|
||||
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
|
||||
let driver = format!("{} {}", info.driver, info.driver_info);
|
||||
startup_step(format_args!(
|
||||
"requesting the device (driver {}) with features [{features}] and the adapter's \
|
||||
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
|
||||
match driver.trim() {
|
||||
"" => "unnamed",
|
||||
driver => driver,
|
||||
},
|
||||
limits.max_buffer_size,
|
||||
limits.max_storage_buffer_binding_size,
|
||||
limits.max_texture_array_layers,
|
||||
));
|
||||
let desc = wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
required_features: features,
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let opened = match info.backend {
|
||||
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
|
||||
_ => None,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let opened = None;
|
||||
let opened = match opened {
|
||||
Some(opened) => opened,
|
||||
None => adapter.request_device(&desc).await,
|
||||
};
|
||||
match opened {
|
||||
Ok((device, queue)) => Ok(Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
}),
|
||||
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
|
||||
}
|
||||
}
|
||||
/// What to tell a player whose system offers no usable graphics adapter.
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
/// missing driver shows here rather than as a missing-DLL dialog.
|
||||
@@ -2530,10 +2596,19 @@ impl Renderer {
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
})];
|
||||
let info = adapter.get_info();
|
||||
let min_sections = if flat::is_broken_intel_vulkan(&info)
|
||||
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
{
|
||||
0
|
||||
} else {
|
||||
flat::MIN_SECTIONS
|
||||
};
|
||||
flat::Flat::new(
|
||||
&device,
|
||||
&bindings,
|
||||
arena.page_vertices(),
|
||||
min_sections,
|
||||
|layout, label, fragment, blend| {
|
||||
pipeline_in(
|
||||
layout,
|
||||
@@ -2764,6 +2839,7 @@ impl Renderer {
|
||||
readback: readback::Readback::default(),
|
||||
info,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
acquire_validation_failures: 0,
|
||||
drawn_sections: 0,
|
||||
section_draws: 0,
|
||||
section_draw_calls: 0,
|
||||
@@ -3765,9 +3841,32 @@ impl Renderer {
|
||||
return Ok(false);
|
||||
}
|
||||
Some(wgpu::CurrentSurfaceTexture::Validation) => {
|
||||
return Err("surface acquisition failed validation".into());
|
||||
// Acquire caught a validation error: the swapchain cannot be
|
||||
// used as configured (wgpu reports a configuration raced with
|
||||
// a submission this way). Give it a fresh configuration like
|
||||
// an Outdated one, and once that has not helped, reattach the
|
||||
// window like a Lost one — a fullscreen loading screen then
|
||||
// skips frames instead of failing every one of them.
|
||||
if self.acquire_validation_failures == 0 {
|
||||
if let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.recreate_surface()?;
|
||||
}
|
||||
self.acquire_validation_failures =
|
||||
self.acquire_validation_failures.saturating_add(1);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
if surface_frame.is_some() {
|
||||
self.acquire_validation_failures = 0;
|
||||
}
|
||||
let size = [self.config.width, self.config.height];
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
@@ -5947,6 +6046,23 @@ mod tests {
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
/// The second try at a backend shows the adapters wgpu hides for reporting
|
||||
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
|
||||
/// Haswell, reports 0.0.0.0 and works).
|
||||
#[test]
|
||||
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
|
||||
let shown = |allow| {
|
||||
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
|
||||
};
|
||||
assert!(shown(true));
|
||||
// The first try keeps wgpu's own filtering, unless `with_env` turns the
|
||||
// flag on from the environment (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER).
|
||||
if std::env::var_os("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER").is_none() {
|
||||
assert!(!shown(false));
|
||||
}
|
||||
}
|
||||
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
|
||||
/// writes it for FXC (shader model 5.1), entry point by entry point.
|
||||
#[test]
|
||||
@@ -6102,7 +6218,7 @@ mod tests {
|
||||
#[test]
|
||||
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
|
||||
let validated = |backends| {
|
||||
super::instance_descriptor(backends, None)
|
||||
super::instance_descriptor(backends, None, false)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ impl SpriteAnimator {
|
||||
group,
|
||||
});
|
||||
}
|
||||
let Some(pipeline) = pipeline else {
|
||||
let Some((pipeline, layout)) = pipeline else {
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
@@ -193,7 +193,6 @@ impl SpriteAnimator {
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
@@ -364,11 +363,15 @@ impl SpriteAnimator {
|
||||
}
|
||||
}
|
||||
|
||||
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
|
||||
/// driver can list compute shaders among its extensions while wgpu writes
|
||||
/// the GLSL version of its core version, which has none (Mesa's
|
||||
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
|
||||
/// unless the device fails to make the pipeline: an OpenGL driver can list
|
||||
/// compute shaders among its extensions while wgpu writes the GLSL version
|
||||
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
|
||||
/// older drivers). A device lost between the scopes' pushes answers pipeline
|
||||
/// creation with a silent placeholder and reports its invalidity only when
|
||||
/// the layout is asked for — inside the scopes that error is caught, outside
|
||||
/// them it is uncaptured and panics (#3866).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => Some(pipeline),
|
||||
None => Some((pipeline, layout)),
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
|
||||
None
|
||||
|
||||
@@ -115,7 +115,7 @@ pub struct Flat {
|
||||
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
|
||||
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
|
||||
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
|
||||
const MIN_SECTIONS: usize = 1600;
|
||||
pub const MIN_SECTIONS: usize = 1600;
|
||||
|
||||
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
|
||||
/// shaders can read storage buffers large enough for an index page.
|
||||
@@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
Ok("1") => return supported(adapter, device),
|
||||
_ => {}
|
||||
}
|
||||
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
|
||||
let info = adapter.get_info();
|
||||
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
|
||||
&& supported(adapter, device)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
|
||||
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
|
||||
/// and flicker.
|
||||
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Vulkan
|
||||
&& info.vendor == 0x8086
|
||||
&& (info.name.contains("ICL")
|
||||
|| info.name.contains("Ice Lake")
|
||||
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
|
||||
}
|
||||
|
||||
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
@@ -254,6 +267,7 @@ impl Flat {
|
||||
device: &wgpu::Device,
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
page_vertices: u64,
|
||||
min_sections: usize,
|
||||
pipeline: impl Fn(
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
@@ -357,11 +371,7 @@ impl Flat {
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
shadow_calls: [0; 2],
|
||||
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
0
|
||||
} else {
|
||||
MIN_SECTIONS
|
||||
},
|
||||
min_sections,
|
||||
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
|
||||
.max(1),
|
||||
}
|
||||
|
||||
@@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20;
|
||||
|
||||
pub struct Uploads {
|
||||
belt: wgpu::util::StagingBelt,
|
||||
chunk: u64,
|
||||
}
|
||||
|
||||
impl Uploads {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
Self::with_chunk(device, CHUNK)
|
||||
}
|
||||
/// A belt whose staging buffers hold `chunk` bytes (larger writes get
|
||||
/// a buffer of their own).
|
||||
/// A belt whose staging buffers hold `chunk` bytes (a larger write gets
|
||||
/// a buffer of its own, rounded up to a power of two).
|
||||
pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self {
|
||||
Self {
|
||||
belt: wgpu::util::StagingBelt::new(device.clone(), chunk),
|
||||
chunk,
|
||||
}
|
||||
}
|
||||
/// A mapped staging slice of at least `size` bytes. The belt gives a
|
||||
/// write larger than its chunk a buffer of exactly that size, keeps it for
|
||||
/// good and only reuses it for writes that fit: an upload that grows a
|
||||
/// little every frame (the entity batch while mobs appear) took a new
|
||||
/// buffer each frame until the game ran out of memory (#2826). Rounded up
|
||||
/// to a power of two, the large buffers it keeps are reused, one per
|
||||
/// doubling.
|
||||
fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> {
|
||||
let size = if size.get() > self.chunk {
|
||||
size.checked_next_power_of_two().unwrap_or(size)
|
||||
} else {
|
||||
size
|
||||
};
|
||||
self.belt.allocate(size, alignment)
|
||||
}
|
||||
/// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip
|
||||
/// level of one array layer, padding rows to the copy alignment.
|
||||
pub fn texture(
|
||||
@@ -41,7 +58,7 @@ impl Uploads {
|
||||
};
|
||||
let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT))
|
||||
.expect("non-zero alignment");
|
||||
let slice = self.belt.allocate(size, alignment);
|
||||
let slice = self.allocate(size, alignment);
|
||||
{
|
||||
let mut view = slice
|
||||
.get_mapped_range_mut()
|
||||
@@ -80,8 +97,20 @@ impl Uploads {
|
||||
let Some(size) = NonZeroU64::new(bytes.len() as u64) else {
|
||||
return;
|
||||
};
|
||||
self.belt
|
||||
.write_buffer(encoder, target, offset, size)
|
||||
// The belt's own `write_buffer` checks, kept now that a large write's
|
||||
// slice is rounded up past them.
|
||||
assert!(
|
||||
offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
|
||||
&& size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT),
|
||||
"buffer upload at {offset} of {size} bytes must be 4-byte aligned"
|
||||
);
|
||||
let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment");
|
||||
let slice = self.allocate(size, alignment).slice(..size.get());
|
||||
encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get());
|
||||
slice
|
||||
.get_mapped_range_mut()
|
||||
.expect("staging belt slices are mapped")
|
||||
.slice(..)
|
||||
.copy_from_slice(bytes);
|
||||
}
|
||||
/// Close this frame's staging buffers: they come back for reuse once the
|
||||
@@ -217,6 +246,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Staging buffers the belt holds, once every submitted frame is done
|
||||
/// with them. The belt only reports its buffers through `Debug`.
|
||||
fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize {
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
// Finishing an empty frame takes back every recalled buffer.
|
||||
uploads.finish(&device.create_command_encoder(&Default::default()));
|
||||
let belt = format!("{:?}", uploads.belt);
|
||||
let count = |field: &str| -> usize {
|
||||
let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2;
|
||||
let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next();
|
||||
digits.and_then(|d| d.parse().ok()).expect(&belt)
|
||||
};
|
||||
count("active_chunks") + count("closed_chunks") + count("free_chunks")
|
||||
}
|
||||
|
||||
/// #2826: a write larger than the chunk that grows a little every frame
|
||||
/// (the entity batch while mobs appear) got a new exact-size buffer each
|
||||
/// frame, all of them kept, until the game ran out of memory.
|
||||
#[test]
|
||||
fn growing_large_writes_reuse_the_belt_buffers() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
const CHUNK: u64 = 1 << 16;
|
||||
const TARGET: usize = 4 * CHUNK as usize;
|
||||
let target = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: TARGET as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: TARGET as u64,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut uploads = Uploads::with_chunk(&device, CHUNK);
|
||||
let mut expected = vec![0u8; TARGET];
|
||||
let frames = 64;
|
||||
for frame in 0..frames {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
// One small write, then the large one, 1.5 to 3.5 chunks long.
|
||||
uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]);
|
||||
expected[TARGET - 4..].fill(9);
|
||||
let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4);
|
||||
let bytes: Vec<u8> = (0..len).map(|i| (i + frame) as u8).collect();
|
||||
uploads.buffer(&mut encoder, &target, 0, &bytes);
|
||||
expected[..len].copy_from_slice(&bytes);
|
||||
encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
// Only the written bytes land: the rest of the target is untouched.
|
||||
assert!(
|
||||
*readback.slice(..).get_mapped_range().unwrap() == expected[..],
|
||||
"frame {frame}"
|
||||
);
|
||||
readback.unmap();
|
||||
}
|
||||
// A chunk for the small writes and a buffer per doubling of the large
|
||||
// one (2 and 4 chunks), maybe one more where a small write took the
|
||||
// start of a buffer the large one needed whole: not one per frame.
|
||||
let kept = kept_buffers(&device, &mut uploads);
|
||||
assert!(
|
||||
kept <= 4,
|
||||
"the belt kept {kept} buffers over {frames} frames"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn belt_buffer_writes_land_in_order_at_their_offsets() {
|
||||
let Some((device, queue)) = device() else {
|
||||
|
||||
@@ -71,6 +71,7 @@ pub mod signs;
|
||||
pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod sound_preview;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
mod sprite_cache;
|
||||
|
||||
@@ -856,6 +856,23 @@ pub fn relight(
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
}
|
||||
/// The columns `changed` touched that border a loaded column not lit yet
|
||||
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
|
||||
/// darkening can take away light such a neighbour sends in and never put it
|
||||
/// back, and nothing relights the column when that neighbour's own light
|
||||
/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine
|
||||
/// keeps one light storage for every chunk, so its edits and its chunk
|
||||
/// lighting see each other. These columns need a full light job, which
|
||||
/// lights them from the blocks of their whole 3x3, that neighbour's included.
|
||||
pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> {
|
||||
let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect();
|
||||
columns.sort_unstable();
|
||||
columns.dedup();
|
||||
columns.retain(|&(x, z)| {
|
||||
(-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz)))
|
||||
});
|
||||
columns
|
||||
}
|
||||
fn relight_region(
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
@@ -1109,6 +1126,70 @@ mod tests {
|
||||
// Without the shape test the room would be lit (slabs have no dampening).
|
||||
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
|
||||
}
|
||||
/// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0)
|
||||
/// was lit from the 3x3's blocks, its torch's light and the far torch's
|
||||
/// in (1, 0) both. Taking its torch away darkens everything the torch
|
||||
/// lit, the far torch's light at the border too, and `relight` cannot
|
||||
/// bring that back from a column it doesn't read. Columns like that are
|
||||
/// lit again whole; without it the border stays at 0 for good once
|
||||
/// (1, 0)'s light lands beside it.
|
||||
#[test]
|
||||
fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() {
|
||||
let tables = tables();
|
||||
let mut w: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
|
||||
for z in -1..=1 {
|
||||
for x in -1..=1 {
|
||||
let mut c = Chunk::empty(x, z);
|
||||
for y in MIN_Y..0 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
if (x, z) == (0, 0) {
|
||||
c.set(14, 0, 8, TORCH);
|
||||
} else if (x, z) == (1, 0) {
|
||||
c.set(6, 0, 8, TORCH);
|
||||
}
|
||||
w.insert((x, z), Arc::new(c));
|
||||
}
|
||||
}
|
||||
let light = |w: &HashMap<(i32, i32), Arc<Chunk>>, (x, z): (i32, i32)| {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c)
|
||||
})
|
||||
});
|
||||
light_chunk(®ion, &tables, true)
|
||||
};
|
||||
let keys: Vec<_> = w.keys().copied().collect();
|
||||
for key in &keys {
|
||||
let lights = light(&w, *key);
|
||||
let c = Arc::make_mut(w.get_mut(key).unwrap());
|
||||
apply_lighting(c, lights);
|
||||
c.lit = *key != (1, 0);
|
||||
}
|
||||
// The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13.
|
||||
assert_eq!(block(&w, 15, 0, 8), 13);
|
||||
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed);
|
||||
let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit));
|
||||
for &key in &again {
|
||||
let lights = light(&w, key);
|
||||
let c = Arc::make_mut(w.get_mut(&key).unwrap());
|
||||
apply_relighting(c, lights, &mut LightChanges::default());
|
||||
}
|
||||
// (1, 0)'s own job lands.
|
||||
let lights = light(&w, (1, 0));
|
||||
let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap());
|
||||
apply_lighting(c, lights);
|
||||
c.lit = true;
|
||||
assert_eq!(block(&w, 16, 0, 8), 8);
|
||||
assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border");
|
||||
assert_eq!(block(&w, 14, 0, 8), 6);
|
||||
assert!(again.contains(&(0, 0)), "{again:?}");
|
||||
}
|
||||
#[test]
|
||||
fn relight_keeps_unchanged_and_unlit_sections() {
|
||||
let mut w = world(|_| {});
|
||||
|
||||
@@ -186,8 +186,15 @@ impl Column {
|
||||
self.push(span);
|
||||
}
|
||||
}
|
||||
/// `kinds`: `state_kinds`, per block state.
|
||||
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
|
||||
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
|
||||
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
|
||||
fn from_chunk(
|
||||
chunk: &Chunk,
|
||||
kinds: &[u8],
|
||||
water: Option<BlockStateId>,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> Self {
|
||||
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
|
||||
let face_light = |y: i32| {
|
||||
let mut sky = 0;
|
||||
@@ -222,11 +229,17 @@ impl Column {
|
||||
let step = if uniform { 16 } else { 1 };
|
||||
for offset in (0..16).step_by(step) {
|
||||
let y = min + offset;
|
||||
let state = BlockStateId(chunk.get(x, y, z));
|
||||
let kind = kind(state);
|
||||
let mut state = BlockStateId(chunk.get(x, y, z));
|
||||
let mut kind = kind(state);
|
||||
if kind & KEPT == 0 {
|
||||
continue;
|
||||
}
|
||||
if kind & WATERY != 0
|
||||
&& let Some(water) = water
|
||||
{
|
||||
state = water;
|
||||
kind = kinds[water.0 as usize];
|
||||
}
|
||||
let light = face_light(y);
|
||||
column.push(Span {
|
||||
min: y as i16,
|
||||
@@ -269,15 +282,28 @@ impl Column {
|
||||
}
|
||||
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
|
||||
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
|
||||
let water = water_source(kinds);
|
||||
Arc::new(std::array::from_fn(|i| {
|
||||
Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32)
|
||||
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
|
||||
}))
|
||||
}
|
||||
/// `state_kinds` bits: kept in columns, a fluid, the air state; the light
|
||||
/// emission is in the high nibble.
|
||||
/// The water source state: the first plain kept fluid without light
|
||||
/// (`minecraft:water` comes before lava, and its level 0 first).
|
||||
fn water_source(kinds: &[u8]) -> Option<BlockStateId> {
|
||||
kinds
|
||||
.iter()
|
||||
.position(|&kind| kind == KEPT | FLUID)
|
||||
.map(|i| BlockStateId(i as u32))
|
||||
}
|
||||
/// `state_kinds` bits: kept in columns, a fluid, the air state, a block
|
||||
/// holding water; the light emission is in the high nibble.
|
||||
const KEPT: u8 = 1;
|
||||
const FLUID: u8 = 2;
|
||||
const AIR: u8 = 4;
|
||||
/// A block holding water that draws as none (kelp, seagrass, bubble columns,
|
||||
/// waterlogged blocks that aren't kept): far terrain draws water there, or
|
||||
/// kelp reaching the surface leaves holes in the sea.
|
||||
const WATERY: u8 = 8;
|
||||
/// What `Column::from_chunk` needs of each block state, read from the facts
|
||||
/// once: matching their names for every block made observing a chunk slow.
|
||||
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
@@ -296,14 +322,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
|| f.full_collision
|
||||
|| f.name.ends_with("_leaves")
|
||||
|| chorus
|
||||
|| fluid);
|
||||
(u8::from(kept) * KEPT)
|
||||
| (u8::from(fluid) * FLUID)
|
||||
|| fluid
|
||||
|| nearly_full(&f.name));
|
||||
let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water;
|
||||
(u8::from(kept || watery) * KEPT)
|
||||
| (u8::from(fluid || watery) * FLUID)
|
||||
| (u8::from(watery) * WATERY)
|
||||
| (u8::from(state == facts.air) * AIR)
|
||||
| (f.emission.min(15) << 4)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Blocks that fill their cell but neither draw solid nor collide as a full
|
||||
/// block: far terrain draws them as blocks, or patches of powder snow leave
|
||||
/// holes in mountains (and paths and fields dips).
|
||||
fn nearly_full(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"minecraft:powder_snow"
|
||||
| "minecraft:dirt_path"
|
||||
| "minecraft:farmland"
|
||||
| "minecraft:honey_block"
|
||||
) || name.ends_with("_stairs")
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct TileKey {
|
||||
pub x: i32,
|
||||
@@ -1085,6 +1126,16 @@ impl Lod {
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
// Retiring fine columns are empty stand-ins, excluded from `active`
|
||||
// while their normal meshes still cover the gap. Request their detail
|
||||
// explicitly: neither the active-tile requests nor sampling an already
|
||||
// ready stand-in can fill it, so its GPU sections would never retire.
|
||||
keys.extend(retiring_detail(
|
||||
&self.plan,
|
||||
&self.retiring,
|
||||
&self.saved,
|
||||
&self.detail_masks,
|
||||
));
|
||||
keys.sort_unstable_by_key(|key| (key.distance(camera), *key));
|
||||
let mut chunks = FxHashSet::default();
|
||||
for key in keys {
|
||||
@@ -1589,11 +1640,84 @@ impl Drop for Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
|
||||
/// can release their normal meshes. The caller keeps the usual 32-column
|
||||
/// request budget and waits for the replacement mesh before changing cutout.
|
||||
fn retiring_detail<'a>(
|
||||
plan: &'a Plan,
|
||||
retiring: &'a FxHashSet<(i32, i32)>,
|
||||
saved: &'a Saved,
|
||||
masks: &'a FxHashMap<TileKey, [u64; 4]>,
|
||||
) -> impl Iterator<Item = TileKey> + 'a {
|
||||
retiring.iter().filter_map(move |&(x, z)| {
|
||||
let key = TileKey { x, z, level: 0 };
|
||||
(plan.nodes.contains(&key)
|
||||
&& !saved.contains_key(&(x, z))
|
||||
&& masks.get(&key) != Some(&[u64::MAX; 4]))
|
||||
.then_some(key)
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::TerrainVertex;
|
||||
#[test]
|
||||
fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() {
|
||||
let column = (12, 0);
|
||||
let fine = TileKey {
|
||||
x: 12,
|
||||
z: 0,
|
||||
level: 0,
|
||||
};
|
||||
let mut plan = Plan::default();
|
||||
plan.nodes.insert(fine);
|
||||
let ready: FxHashMap<TileKey, ()> = [(fine, ())].into_iter().collect();
|
||||
let retiring = [column].into_iter().collect();
|
||||
let saved = Saved::default();
|
||||
let mut masks = FxHashMap::default();
|
||||
// A covered stand-in is ready, but empty: ordinary sampling skips it,
|
||||
// and selection leaves it out of `active`. It cannot yet replace the
|
||||
// old full-detail column, even though the near world has unloaded it.
|
||||
assert!(!plan.replaces(column, &ready, |key| masks.get(&key)
|
||||
== Some(&[u64::MAX; 4])));
|
||||
assert_eq!(
|
||||
retiring_detail(&plan, &retiring, &saved, &masks).collect::<Vec<_>>(),
|
||||
[fine]
|
||||
);
|
||||
// The existing RequestDetail -> apply_fine path marks complete detail;
|
||||
// only then may selection retire it and publish the new mesh cutout.
|
||||
masks.insert(fine, [u64::MAX; 4]);
|
||||
assert!(
|
||||
retiring_detail(&plan, &retiring, &saved, &masks)
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
assert!(plan.replaces(column, &ready, |key| masks.get(&key)
|
||||
== Some(&[u64::MAX; 4])));
|
||||
}
|
||||
#[test]
|
||||
fn retiring_detail_requests_only_planned_columns_still_missing_detail() {
|
||||
let fine = |x| TileKey { x, z: 0, level: 0 };
|
||||
let mut plan = Plan::default();
|
||||
plan.nodes.extend((0..4).map(fine));
|
||||
plan.nodes.insert(fine(5));
|
||||
let retiring = (0..5).map(|x| (x, 0)).collect();
|
||||
let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let masks = [
|
||||
(fine(2), [u64::MAX; 4]),
|
||||
(fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect();
|
||||
requested.sort_unstable();
|
||||
// Saved or complete detail needs no request; an unplanned retiree and
|
||||
// a planned column that is not retiring must not consume the budget.
|
||||
assert_eq!(requested, [fine(0), fine(3)]);
|
||||
}
|
||||
#[test]
|
||||
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
|
||||
let leaves = |plan: &Plan| {
|
||||
plan.nodes
|
||||
@@ -1840,6 +1964,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn nearly_full_blocks_are_kept() {
|
||||
assert!(nearly_full("minecraft:powder_snow"));
|
||||
assert!(nearly_full("minecraft:dirt_path"));
|
||||
assert!(nearly_full("minecraft:oak_stairs"));
|
||||
assert!(!nearly_full("minecraft:oak_fence"));
|
||||
assert!(!nearly_full("minecraft:wheat"));
|
||||
}
|
||||
#[test]
|
||||
fn kelp_to_the_surface_is_drawn_as_water() {
|
||||
// 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none).
|
||||
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY];
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in [3, 4] {
|
||||
for y in MIN_Y..40 {
|
||||
chunk.set(x, y, 3, 1);
|
||||
}
|
||||
}
|
||||
for y in 40..63 {
|
||||
chunk.set(3, y, 3, 3);
|
||||
chunk.set(4, y, 3, 2);
|
||||
}
|
||||
let water = water_source(&kinds);
|
||||
assert_eq!(water, Some(BlockStateId(2)));
|
||||
let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3);
|
||||
let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3);
|
||||
assert_eq!(kelp.spans, sea.spans);
|
||||
assert_eq!(
|
||||
kelp.spans.last().map(|s| (s.min, s.max, s.state)),
|
||||
Some((40, 63, BlockStateId(2)))
|
||||
);
|
||||
assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2))));
|
||||
assert_eq!(kelp.surface.unwrap().height, 40);
|
||||
}
|
||||
#[test]
|
||||
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
|
||||
let span = |min, max, state| Span {
|
||||
min,
|
||||
@@ -2035,6 +2193,24 @@ mod tests {
|
||||
chunk.set(8, 71, 8, chorus.0);
|
||||
chunk.set(8, 72, 8, chorus.0);
|
||||
chunk.set(8, 73, 8, flower.0);
|
||||
// Kelp to the surface is water; powder snow and a waterlogged stair
|
||||
// are kept as themselves.
|
||||
let state = |name: &str| assets.registry.default_state(name).unwrap();
|
||||
let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow"));
|
||||
let stair = facts
|
||||
.states
|
||||
.iter()
|
||||
.position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged)
|
||||
.map(|i| BlockStateId(i as u32))
|
||||
.unwrap();
|
||||
chunk.set(2, 54, 2, stone.0);
|
||||
for y in 55..64 {
|
||||
chunk.set(2, y, 2, kelp.0);
|
||||
}
|
||||
chunk.set(6, 90, 6, stone.0);
|
||||
chunk.set(6, 91, 6, powder_snow.0);
|
||||
chunk.set(10, 54, 10, stone.0);
|
||||
chunk.set(10, 55, 10, stair.0);
|
||||
lod.observe(Arc::new(chunk.clone()), &facts);
|
||||
receive_observed(&mut lod);
|
||||
let observed = &lod.saved[&(-1, -1)];
|
||||
@@ -2052,6 +2228,12 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
|
||||
);
|
||||
let kelp_column = &observed[2 * 16 + 2];
|
||||
assert_eq!(kelp_column.surface.unwrap().height, 55);
|
||||
assert_eq!(kelp_column.surface.unwrap().water, Some((64, water)));
|
||||
assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow);
|
||||
assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92);
|
||||
assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair);
|
||||
|
||||
let fine = TileKey {
|
||||
x: -1,
|
||||
|
||||
@@ -45,9 +45,20 @@ pub fn data_dir() -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
|
||||
/// platform data folder (launcher/src/backend.rs `game_home`).
|
||||
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
|
||||
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Makes `game_home` return `home` for the rest of the run (`--home`).
|
||||
pub fn set_game_home(home: PathBuf) {
|
||||
let _ = HOME.set(home);
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
|
||||
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
|
||||
pub fn game_home() -> PathBuf {
|
||||
if let Some(home) = HOME.get() {
|
||||
return home.clone();
|
||||
}
|
||||
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => data_dir().join("horizon"),
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct ModelInstance {
|
||||
pub layer: u32,
|
||||
pub shade: f32,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct PreparedModels {
|
||||
pub catalog: StateModels,
|
||||
pub placements: Vec<(BlockPos, BlockStateId)>,
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct BakedModel {
|
||||
pub cube: Option<[super::cube::CubeStyle; 6]>,
|
||||
}
|
||||
type VariantKey = (String, [u8; 3], bool);
|
||||
/// Empty by default: no state has a model (a dedicated server, which draws
|
||||
/// nothing).
|
||||
#[derive(Default)]
|
||||
pub struct StateModels {
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
|
||||
@@ -143,6 +143,7 @@ impl Opt {
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the creative tabs and the mouse don't read them yet.
|
||||
| Opt::OperatorItemsTab
|
||||
| Opt::QuitShortcuts
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
@@ -950,6 +951,24 @@ pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
|
||||
apply_preset(s);
|
||||
}
|
||||
}
|
||||
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
|
||||
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
|
||||
/// callback, which `OptionInstance.set` runs only for a new value).
|
||||
fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
|
||||
let volume = |s: &Settings| match opt {
|
||||
Opt::Volume(0) => Some(s.master_volume),
|
||||
Opt::Volume(i) => Some(s.volumes[i]),
|
||||
_ => None,
|
||||
};
|
||||
let before = volume(s);
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::SliderDrag);
|
||||
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
|
||||
&& before != Some(after)
|
||||
{
|
||||
events.push(Event::Volume(source, after));
|
||||
}
|
||||
}
|
||||
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
|
||||
/// filtering and anisotropy have no option here yet).
|
||||
pub fn apply_preset(s: &mut Settings) {
|
||||
@@ -1104,6 +1123,9 @@ pub enum Event {
|
||||
Changed,
|
||||
/// A slider is being dragged: apply settings live but defer saving.
|
||||
SliderDrag,
|
||||
/// A category volume slider (`SoundSource` index) moved to a new value,
|
||||
/// after its `SliderDrag` (the sound slider's `onValueUpdate`).
|
||||
Volume(usize, f32),
|
||||
/// The language changed (reload translations).
|
||||
Language(String),
|
||||
/// The pack selection changed (reload resources).
|
||||
@@ -2745,8 +2767,7 @@ impl OptionsUi {
|
||||
{
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::SliderDrag);
|
||||
drag_slider(opt, s, v, &mut events);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -2859,8 +2880,7 @@ impl OptionsUi {
|
||||
self.dragging = Some(opt);
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::SliderDrag);
|
||||
drag_slider(opt, s, v, &mut events);
|
||||
}
|
||||
WKind::LanguageRow { code, .. } => {
|
||||
if self.language_choice.as_deref() == Some(code.as_str()) {
|
||||
@@ -3422,6 +3442,15 @@ mod tests {
|
||||
Opt::QuitShortcuts.tooltip_key(&s),
|
||||
"options.quitShortcuts.tooltip"
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn operator_items_tab_option_can_be_switched() {
|
||||
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
|
||||
assert!(Opt::OperatorItemsTab.available());
|
||||
let mut s = Settings::default();
|
||||
assert!(!s.operator_items_tab);
|
||||
Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]);
|
||||
assert!(s.operator_items_tab);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4102,7 +4131,7 @@ mod tests {
|
||||
for cx in [-100.0, 250.0, 1000.0] {
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
|
||||
vec![Event::SliderDrag]
|
||||
vec![Event::SliderDrag, Event::Volume(0, s.master_volume)]
|
||||
);
|
||||
}
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
|
||||
@@ -4112,6 +4141,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_volume_slider_reports_its_category_and_value() {
|
||||
let atlas = atlas();
|
||||
let size = (480.0, 320.0);
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Sound);
|
||||
let hostile = SOUND_SOURCES.iter().position(|n| *n == "hostile").unwrap();
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let slider = widgets
|
||||
.iter()
|
||||
.find(|wd| matches!(wd.kind, WKind::Slider { opt: Opt::Volume(i), .. } if i == hostile))
|
||||
.unwrap();
|
||||
let y = slider.y + 1.0;
|
||||
let x = slider.x + 4.0 + (slider.w - 8.0) * 0.5;
|
||||
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
let volume = s.volumes[hostile];
|
||||
assert!((volume - 0.5).abs() < 1e-3, "{volume}");
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::SliderDrag, Event::Volume(hostile, volume)]
|
||||
);
|
||||
// OptionInstance.set: an unchanged value runs no callback.
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, x, -50.0, size, [854, 480], true, false),
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_sliders_follow_the_pointer_and_snap_on_release() {
|
||||
let atlas = atlas();
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
|
||||
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
|
||||
//! calls `preview` when there is no level).
|
||||
use crate::settings::SOUND_SOURCES;
|
||||
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
|
||||
|
||||
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
|
||||
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
|
||||
pub fn preview_event(source: usize) -> Option<&'static str> {
|
||||
Some(match *SOUND_SOURCES.get(source)? {
|
||||
"record" => "minecraft:block.note_block.guitar",
|
||||
"weather" => "minecraft:entity.lightning_bolt.thunder",
|
||||
"block" => "minecraft:block.grass.place",
|
||||
"hostile" => "minecraft:entity.zombie.ambient",
|
||||
// SoundEvents.COW_SOUNDS.get(CowSoundVariants.SoundSet.CLASSIC).ambientSound()
|
||||
"neutral" => "minecraft:entity.cow.ambient",
|
||||
"player" => "minecraft:entity.generic.eat",
|
||||
"ambient" => "minecraft:ambient.cave",
|
||||
"ui" => "minecraft:ui.button.click",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `SoundPreviewHandler`'s `activePreview` and `previousCategory`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SoundPreview {
|
||||
active: Option<InstanceId>,
|
||||
previous: Option<usize>,
|
||||
}
|
||||
|
||||
impl SoundPreview {
|
||||
/// `SoundPreviewHandler.preview(soundManager, category, volume)`: stop the
|
||||
/// preview of another category, then, unless the last preview still
|
||||
/// plays, play this category's sound (`SimpleSoundInstance.forUI` at
|
||||
/// pitch 1 and the slider's volume).
|
||||
pub fn preview(&mut self, sounds: &mut impl Sounds, source: usize, volume: f32) {
|
||||
self.stop_other_category_preview(sounds, source);
|
||||
if !self.can_play_sound(sounds) {
|
||||
return;
|
||||
}
|
||||
let Some(event) = preview_event(source) else {
|
||||
return;
|
||||
};
|
||||
if let Some(old) = self.active.take() {
|
||||
sounds.release(old);
|
||||
}
|
||||
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
|
||||
self.active = Some(sounds.play_instance(instance, true).0);
|
||||
}
|
||||
|
||||
/// The preview playing now, if any.
|
||||
pub fn active(&self) -> Option<InstanceId> {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// `stopOtherCategoryPreview`.
|
||||
fn stop_other_category_preview(&mut self, sounds: &mut impl Sounds, source: usize) {
|
||||
if self.previous != Some(source) {
|
||||
self.previous = Some(source);
|
||||
if let Some(active) = self.active {
|
||||
sounds.stop_instance(active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `canPlaySound`: no preview yet, or the last one has finished.
|
||||
fn can_play_sound(&self, sounds: &mut impl Sounds) -> bool {
|
||||
self.active.is_none_or(|active| !sounds.is_active(active))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
|
||||
use crate::sound_events::SoundRegistry;
|
||||
|
||||
fn engine() -> SoundEngine {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"entity.zombie.ambient": {"sounds": ["mob/zombie/say1"]},
|
||||
"entity.cow.ambient": {"sounds": ["mob/cow/say1"]},
|
||||
"ui.button.click": {"sounds": ["random/click"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
SoundEngine::new(registry)
|
||||
}
|
||||
|
||||
fn source(name: &str) -> usize {
|
||||
SOUND_SOURCES.iter().position(|s| *s == name).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_category_previews_its_vanilla_sound() {
|
||||
let expected = [
|
||||
("master", None),
|
||||
("music", None),
|
||||
("record", Some("minecraft:block.note_block.guitar")),
|
||||
("weather", Some("minecraft:entity.lightning_bolt.thunder")),
|
||||
("block", Some("minecraft:block.grass.place")),
|
||||
("hostile", Some("minecraft:entity.zombie.ambient")),
|
||||
("neutral", Some("minecraft:entity.cow.ambient")),
|
||||
("player", Some("minecraft:entity.generic.eat")),
|
||||
("ambient", Some("minecraft:ambient.cave")),
|
||||
("voice", None),
|
||||
("ui", Some("minecraft:ui.button.click")),
|
||||
];
|
||||
for (name, event) in expected {
|
||||
assert_eq!(preview_event(source(name)), event, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
fn slide(
|
||||
preview: &mut SoundPreview,
|
||||
engine: &mut SoundEngine,
|
||||
backend: &mut Null,
|
||||
name: &str,
|
||||
volume: f32,
|
||||
) {
|
||||
preview.preview(&mut Bound { engine, backend }, source(name), volume);
|
||||
}
|
||||
|
||||
/// Ticks enough for the engine to drop finished or stopped channels.
|
||||
fn settle(engine: &mut SoundEngine, backend: &mut Null) {
|
||||
for _ in 0..40 {
|
||||
engine.tick(false, &TickContext::default(), backend);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moving_slider_plays_one_preview_at_a_time_and_switching_stops_it() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
let mut preview = SoundPreview::default();
|
||||
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.4);
|
||||
let first = preview
|
||||
.active()
|
||||
.expect("the hostile slider plays a preview");
|
||||
let instance = engine.instance(first).unwrap();
|
||||
assert_eq!(instance.identifier, "minecraft:entity.zombie.ambient");
|
||||
assert_eq!(instance.source, UI);
|
||||
assert_eq!((instance.volume, instance.pitch), (0.4, 1.0));
|
||||
assert!(instance.relative);
|
||||
assert!(engine.is_active(first));
|
||||
// canPlaySound: still playing, so dragging further does not restart it.
|
||||
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
|
||||
assert_eq!(preview.active(), Some(first));
|
||||
assert_eq!(engine.active_instances(), vec![first]);
|
||||
// Once it has finished, the next move plays it again.
|
||||
for stopped in backend.stopped.values_mut() {
|
||||
*stopped = true;
|
||||
}
|
||||
settle(&mut engine, &mut backend);
|
||||
assert!(!engine.is_active(first));
|
||||
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
|
||||
let second = preview.active().unwrap();
|
||||
assert_ne!(second, first);
|
||||
assert!(engine.is_active(second));
|
||||
// Another category stops the playing preview (SoundManager.stop); the
|
||||
// engine lets its channel go on the next ticks, then that category's
|
||||
// own sound plays.
|
||||
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
|
||||
assert!(backend.stopped.values().all(|stopped| *stopped));
|
||||
settle(&mut engine, &mut backend);
|
||||
assert!(engine.active_instances().is_empty());
|
||||
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
|
||||
let third = preview.active().unwrap();
|
||||
assert_eq!(
|
||||
engine.instance(third).unwrap().identifier,
|
||||
"minecraft:entity.cow.ambient"
|
||||
);
|
||||
// Master has no sound but still stops the other category's preview.
|
||||
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
|
||||
settle(&mut engine, &mut backend);
|
||||
assert!(engine.active_instances().is_empty());
|
||||
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
|
||||
assert!(engine.active_instances().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
|
||||
//! so stale work is dropped.
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
@@ -81,6 +82,12 @@ pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
|
||||
None
|
||||
}
|
||||
/// A decorated proto chunk's column sections (`Chunk::sections`
|
||||
/// indexes) holding a block other than air: what its neighbours' light
|
||||
/// layers count once it is at INITIALIZE_LIGHT.
|
||||
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
|
||||
None
|
||||
}
|
||||
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
|
||||
None
|
||||
}
|
||||
@@ -275,6 +282,7 @@ impl Mailbox {
|
||||
pub struct Streaming {
|
||||
shared: Arc<Shared>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
@@ -653,9 +661,14 @@ impl Streaming {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.light_view(p))
|
||||
};
|
||||
let non_air_sections = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
||||
};
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
non_air_sections,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
@@ -956,6 +969,15 @@ impl Streaming {
|
||||
pub fn is_lit(&self, x: i32, z: i32) -> bool {
|
||||
self.lit.contains(&(x, z))
|
||||
}
|
||||
/// The non-air sections of a column that is a decorated proto chunk
|
||||
/// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level
|
||||
/// chunk's light layers count them (#4294).
|
||||
pub fn proto_sections(&self, x: i32, z: i32) -> Option<SectionMask> {
|
||||
if !self.decorated.contains(&(x, z)) {
|
||||
return None;
|
||||
}
|
||||
(self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?)
|
||||
}
|
||||
/// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the
|
||||
/// furthest step this column has reached, `None` before it is asked for.
|
||||
pub fn latest_status(
|
||||
@@ -1475,6 +1497,7 @@ impl Streaming {
|
||||
&mut changed,
|
||||
);
|
||||
Self::mark_light_changes(world, &changed);
|
||||
Self::relight_beside_unlit(world, &changed);
|
||||
if !self.light_snapshots.is_empty() {
|
||||
for p in positions {
|
||||
self.light_edit_sequence += 1;
|
||||
@@ -1504,6 +1527,7 @@ impl Streaming {
|
||||
&mut changed,
|
||||
);
|
||||
Self::mark_light_changes(world, &changed);
|
||||
Self::relight_beside_unlit(world, &changed);
|
||||
}
|
||||
let oldest = self.light_snapshots.values().copied().min();
|
||||
match oldest {
|
||||
@@ -1511,6 +1535,14 @@ impl Streaming {
|
||||
None => self.light_log.clear(),
|
||||
}
|
||||
}
|
||||
/// A column whose light an edit changed beside a column not lit yet gets
|
||||
/// a full light job ([`crate::light::beside_unlit`]).
|
||||
fn relight_beside_unlit(world: &mut World, changed: &LightChanges) {
|
||||
let columns = crate::light::beside_unlit(changed, |x, z| {
|
||||
world.chunks.get(&(x, z)).is_some_and(|c| !c.lit)
|
||||
});
|
||||
world.dirty_light.extend(columns);
|
||||
}
|
||||
/// Sections whose meshes sample a changed light value (faces and corners
|
||||
/// read light from neighbouring blocks).
|
||||
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
|
||||
|
||||
@@ -262,6 +262,14 @@ impl World {
|
||||
pub fn layout(&self) -> ColumnLayout {
|
||||
self.shape.column()
|
||||
}
|
||||
/// The lowest block Y the level's chunks hold.
|
||||
pub fn bottom_y(&self) -> i32 {
|
||||
self.layout().min_y()
|
||||
}
|
||||
/// One above the highest block Y the level's chunks hold.
|
||||
pub fn top_y(&self) -> i32 {
|
||||
self.layout().max_y()
|
||||
}
|
||||
/// Resident storage write; marks the touched sections (and neighbours on
|
||||
/// section borders) for remeshing and the surrounding chunks for relighting.
|
||||
/// Neighbour updates, drops and block entities belong to the gameplay layer.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! The creative inventory, for tools/test_creative_parity.py: loads an
|
||||
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
|
||||
//! and prints the tabs it shows (canonical stacks) and the search tab's
|
||||
//! results for every query of the vanilla oracle's output.
|
||||
//! results for every query of the vanilla oracle's output; with `permissions`,
|
||||
//! as an operator with the Operator Items Tab option on.
|
||||
//!
|
||||
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
|
||||
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]
|
||||
use horizon_client::{
|
||||
creative::{Creative, Names},
|
||||
item_data::canon_stack,
|
||||
@@ -13,11 +14,13 @@ use std::{fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
|
||||
let usage =
|
||||
"usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]";
|
||||
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
|
||||
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
|
||||
let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read(
|
||||
args.get(2).ok_or(usage)?,
|
||||
)?)?)?;
|
||||
creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names);
|
||||
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
|
||||
let tabs: Vec<Value> = creative
|
||||
.tabs
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
//!
|
||||
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
items::Items,
|
||||
mobs::{
|
||||
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
|
||||
},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
@@ -65,6 +70,13 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let loot = LootData::load(root, &PackOverlay::NONE)?;
|
||||
// Block loot for an enderman's carried block (BlockState.getDrops).
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let block_items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
|
||||
// The stand-in level's random (RandomSource.create(0)), never reset.
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() || raw.starts_with('#') {
|
||||
@@ -121,6 +133,13 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
|
||||
}
|
||||
m.extra.sheared = get("sheared", "0") == "1";
|
||||
if let Some(block) = c.get("carried") {
|
||||
m.extra.carried = Some(
|
||||
registry
|
||||
.default_state(block)
|
||||
.ok_or_else(|| format!("unknown block {block}"))?,
|
||||
);
|
||||
}
|
||||
if get("leader", "0") == "1" {
|
||||
raider::state_mut(&mut m).patrol_leader = true;
|
||||
}
|
||||
@@ -272,6 +291,23 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
let stacks = enderman::carried_block_drops(
|
||||
&block_items,
|
||||
registry,
|
||||
state,
|
||||
&mut level_random,
|
||||
Some((&mut sequences, world_seed)),
|
||||
);
|
||||
for s in stacks {
|
||||
let _ = write!(out, "drop {} {}", s.item, s.count);
|
||||
if s.damage != 0 {
|
||||
let _ = write!(out, " damage={}", s.damage);
|
||||
}
|
||||
let _ = writeln!(out);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
mod translucency_fixture;
|
||||
mod trident_probe;
|
||||
mod weather_fixture;
|
||||
mod world_border_render_fixture;
|
||||
mod world_options_fixture;
|
||||
@@ -191,6 +192,7 @@ probes! {
|
||||
text_display_fixture,
|
||||
text_display_render_fixture,
|
||||
trading_fixture,
|
||||
trident_probe,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
world_border_render_fixture,
|
||||
|
||||
@@ -76,6 +76,7 @@ pub fn main() -> Result<()> {
|
||||
raining_at: &|_| false,
|
||||
height_no_leaves: &|_| 0,
|
||||
firefly_bush_sounds: false,
|
||||
creaking_active: false,
|
||||
sky_darken: 0,
|
||||
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
|
||||
model_offset: &|_, _, _| None,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Thrown tridents over the client's resident scene (`arrows::clip_block_stop`),
|
||||
//! for tools/test_trident_parity.py: `AbstractArrow.tick`'s block step and
|
||||
//! `AbstractArrow.onHitBlock`, one line of state per fixture.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
Result, arrows, assets::Assets, blocks::BlockFacts, collisions, world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
fn number(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn vector(v: &Value) -> Vec3 {
|
||||
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
|
||||
}
|
||||
fn bits(v: Vec3) -> String {
|
||||
format!(
|
||||
"{:016x},{:016x},{:016x}",
|
||||
v.x.to_bits(),
|
||||
v.y.to_bits(),
|
||||
v.z.to_bits()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().skip(1).collect();
|
||||
if args.len() != 3 {
|
||||
return Err("usage: trident_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
|
||||
}
|
||||
let root = PathBuf::from(&args[0]);
|
||||
let dir = PathBuf::from(&args[1]);
|
||||
let assets = Assets::load(&root)?;
|
||||
let render = assets.reload(&root)?;
|
||||
let mut scene = World::test_scene(&assets, &render, 1)?;
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
serde_json::to_vec(
|
||||
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
|
||||
)?,
|
||||
)?;
|
||||
if args[2] == "--describe" {
|
||||
return Ok(());
|
||||
}
|
||||
let binding = collisions::load(&root, assets.registry)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let side = &args[2];
|
||||
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
|
||||
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
|
||||
let f: Value = serde_json::from_str(&line?)?;
|
||||
let mut position = vector(&f["position"]);
|
||||
let mut velocity = vector(&f["velocity"]);
|
||||
let mut in_ground = false;
|
||||
let mut shake = 0u32;
|
||||
for op in f["ops"].as_array().unwrap() {
|
||||
match op["mode"].as_str().unwrap() {
|
||||
"block" => {
|
||||
let p = &op["position"];
|
||||
let mut state = assets
|
||||
.registry
|
||||
.default_state(op["name"].as_str().unwrap())
|
||||
.ok_or("missing fixture block")?;
|
||||
if let Some(properties) = op["properties"].as_object() {
|
||||
let mut definition = assets.registry.state(state).unwrap().clone();
|
||||
for (name, value) in properties {
|
||||
definition
|
||||
.properties
|
||||
.insert(name.clone(), value.as_str().unwrap().into());
|
||||
}
|
||||
state = assets
|
||||
.registry
|
||||
.id(&definition)
|
||||
.ok_or("missing fixture state")?;
|
||||
}
|
||||
scene.set(
|
||||
p[0].as_i64().unwrap() as i32,
|
||||
p[1].as_i64().unwrap() as i32,
|
||||
p[2].as_i64().unwrap() as i32,
|
||||
state,
|
||||
)?;
|
||||
}
|
||||
// Ops only the vanilla oracle needs.
|
||||
"level_seed" | "owner" | "pickup" | "touch" | "target" | "velocity" | "ignite" => {}
|
||||
"tick" => {
|
||||
// AbstractArrow.tick: shakeTime down, then the block step.
|
||||
shake = shake.saturating_sub(1);
|
||||
if in_ground {
|
||||
continue;
|
||||
}
|
||||
if let Some((stop, _)) =
|
||||
arrows::clip_block_stop(&scene, &facts, &binding, position, velocity)
|
||||
{
|
||||
// onHitBlock: pulled back, stopped, stuck, shaking 7 ticks.
|
||||
position = stop;
|
||||
velocity = Vec3::ZERO;
|
||||
in_ground = true;
|
||||
shake = 7;
|
||||
} else {
|
||||
// A MISS moves the whole movement, then air drag and gravity.
|
||||
position = Vec3::new(
|
||||
position.x + velocity.x,
|
||||
position.y + velocity.y,
|
||||
position.z + velocity.z,
|
||||
);
|
||||
velocity = Vec3::new(
|
||||
velocity.x * f64::from(0.99f32),
|
||||
velocity.y * f64::from(0.99f32) - 0.05,
|
||||
velocity.z * f64::from(0.99f32),
|
||||
);
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown operation {other}").into()),
|
||||
}
|
||||
}
|
||||
out.write_all(
|
||||
format!(
|
||||
"trident {} {} {in_ground} {shake}\n",
|
||||
bits(position),
|
||||
bits(velocity)
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -101,6 +101,8 @@ enum Animate {
|
||||
DeadBush,
|
||||
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
|
||||
DryGrass,
|
||||
/// A creaking heart that is not uprooted (`CreakingHeartBlock.animateTick`).
|
||||
CreakingHeart,
|
||||
}
|
||||
fn step(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -236,6 +238,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"firefly_bush" => Animate::FireflyBush,
|
||||
"open_eyeblossom" => Animate::OpenEyeblossom,
|
||||
"pale_hanging_moss" => Animate::PaleHangingMoss,
|
||||
"creaking_heart" if prop("creaking_heart_state").as_deref() != Some("uprooted") => {
|
||||
Animate::CreakingHeart
|
||||
}
|
||||
"dead_bush" => Animate::DeadBush,
|
||||
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
@@ -442,6 +447,8 @@ pub struct AnimateContext<'a> {
|
||||
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
|
||||
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
|
||||
pub firefly_bush_sounds: bool,
|
||||
/// `EnvironmentAttributes.CREAKING_ACTIVE` (overworld day timeline).
|
||||
pub creaking_active: bool,
|
||||
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
|
||||
pub sky_darken: i32,
|
||||
/// Creative main-hand marker block and a resolver for each placed state's sprite.
|
||||
@@ -1259,6 +1266,39 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::CreakingHeart => {
|
||||
// CreakingHeartBlock.animateTick: the idle creak is drawn only while
|
||||
// the attribute is on, then needs pale oak logs on all six sides
|
||||
// (`CreakingHeartBlock.isSurroundedByLogs`).
|
||||
if ctx.creaking_active
|
||||
&& next_int(engine.random(), 16) == 0
|
||||
&& [
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
]
|
||||
.iter()
|
||||
.all(|d| {
|
||||
let p = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
|
||||
tables
|
||||
.pale_oak_logs
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
sound(
|
||||
engine,
|
||||
"minecraft:block.creaking_heart.idle",
|
||||
[fx, fy, fz],
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
Animate::DeadBush => {
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
|
||||
if next_int(engine.random(), 130) == 0 {
|
||||
|
||||
@@ -186,6 +186,8 @@ pub struct ArrowState {
|
||||
/// The arrow's own random (`Entity.random`).
|
||||
pub random: LegacyRandom,
|
||||
pub events: Vec<ArrowEvent>,
|
||||
/// The speed (`deltaMovement.length()`) it hit a block at, before it stuck.
|
||||
pub hit_speed: f64,
|
||||
}
|
||||
impl fmt::Debug for ArrowState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
@@ -230,6 +232,7 @@ impl ArrowState {
|
||||
removed: false,
|
||||
random,
|
||||
events: Vec::new(),
|
||||
hit_speed: 0.0,
|
||||
}
|
||||
}
|
||||
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
|
||||
@@ -375,6 +378,61 @@ impl ArrowScene<'_, '_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
/// `AbstractArrow.tick`'s block phase for a thrown trident: the zero-width
|
||||
/// `level().clip(new ClipContext(position(), position().add(movement),
|
||||
/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then
|
||||
/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns
|
||||
/// the position to stop at (pull-back included) and the block that was hit.
|
||||
pub fn clip_block_stop(
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
from: Vec3,
|
||||
movement: Vec3,
|
||||
) -> Option<(Vec3, BlockPos)> {
|
||||
let to = Vec3::new(
|
||||
from.x + movement.x,
|
||||
from.y + movement.y,
|
||||
from.z + movement.z,
|
||||
);
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
if facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()?;
|
||||
Some((back_off(hit.location, movement), hit.block))
|
||||
}
|
||||
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
|
||||
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
|
||||
pub fn back_off(location: Vec3, movement: Vec3) -> Vec3 {
|
||||
fn signum(v: f64) -> f64 {
|
||||
if v > 0.0 {
|
||||
1.0
|
||||
} else if v < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
let offset = f64::from(0.05f32);
|
||||
Vec3::new(
|
||||
location.x - signum(movement.x) * offset,
|
||||
location.y - signum(movement.y) * offset,
|
||||
location.z - signum(movement.z) * offset,
|
||||
)
|
||||
}
|
||||
fn bounds(at: Vec3) -> Aabb {
|
||||
// EntityDimensions.makeBoundingBox: half the float width.
|
||||
let w = f64::from(WIDTH / 2.0);
|
||||
@@ -664,7 +722,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
|
||||
self.st.fall_distance *= 0.5;
|
||||
}
|
||||
if self.position().y < f64::from(crate::chunk::MIN_Y - 64) {
|
||||
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
|
||||
self.st.removed = true;
|
||||
}
|
||||
self.st.first_tick = false;
|
||||
@@ -848,6 +906,8 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// it moved on, stop, and stick.
|
||||
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
|
||||
self.st.last_state = Some(state);
|
||||
// BlockState.onProjectileHit still sees the deltaMovement it hit at.
|
||||
self.st.hit_speed = self.p.velocity.length();
|
||||
// Projectile.onHitBlock: BlockState.onProjectileHit.
|
||||
if self.st.on_fire()
|
||||
&& let Some(reaction) = self.block_reaction(state)
|
||||
@@ -1159,10 +1219,10 @@ impl FluidInteractionWorld for Ticker<'_, '_, '_, '_> {
|
||||
Ok(self.scene.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
let layout = self.scene.world.layout();
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
minimum_section_y: layout.min_y() >> 4,
|
||||
has_fluid: crate::chunk::every_section(layout),
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
|
||||
@@ -24,6 +24,31 @@ pub struct Tab {
|
||||
/// tab is selected (`set_saved_hotbars`).
|
||||
pub hotbar: bool,
|
||||
}
|
||||
impl Tab {
|
||||
/// The tab without its stacks.
|
||||
fn header(&self) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
title: self.title.clone(),
|
||||
icon: self.icon.clone(),
|
||||
items: Vec::new(),
|
||||
top: self.top,
|
||||
column: self.column,
|
||||
search: self.search,
|
||||
inventory: self.inventory,
|
||||
hotbar: self.hotbar,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A tab as exported, built with permissions: `operator` marks the stacks a
|
||||
/// build without them leaves out (the export's `permissions_only`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Exported {
|
||||
tab: Tab,
|
||||
/// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`).
|
||||
category: bool,
|
||||
operator: Vec<bool>,
|
||||
}
|
||||
/// What the client's creative search trees (SessionSearchTrees) index for one
|
||||
/// stack of the search tab: its creative tooltip lines and its item tags.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -37,6 +62,8 @@ struct SearchEntry {
|
||||
tags: Vec<(String, String)>,
|
||||
}
|
||||
pub struct Creative {
|
||||
/// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current
|
||||
/// permissions put in them.
|
||||
pub tabs: Vec<Tab>,
|
||||
pub selected: usize,
|
||||
/// Scroll position 0..1 (scrollOffs).
|
||||
@@ -48,6 +75,13 @@ pub struct Creative {
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
/// Every exported tab, and the search tab's stacks with their entries and
|
||||
/// whether only permissions list them: `rebuild` keeps what is shown.
|
||||
exported: Vec<Exported>,
|
||||
exported_search: Vec<(ItemStack, SearchEntry, bool)>,
|
||||
/// The `hasPermissions` the tabs were last built with
|
||||
/// (`CreativeModeTabs.tryRebuildTabContents`).
|
||||
permissions: bool,
|
||||
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
|
||||
/// search changes (selectTab, refreshSearchResults), not on every frame.
|
||||
shown: Mutex<Option<Shown>>,
|
||||
@@ -202,14 +236,22 @@ impl Creative {
|
||||
}
|
||||
pub fn from_json(raw: &Value) -> Result<Self> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut search_items = Vec::new();
|
||||
let mut search_index = Vec::new();
|
||||
let mut search = Vec::new();
|
||||
for t in raw["tabs"].as_array().into_iter().flatten() {
|
||||
// The stacks only a build with permissions lists, by index.
|
||||
let only: Vec<u64> = t["permissions_only"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.collect();
|
||||
let mut items = Vec::new();
|
||||
for v in t["items"].as_array().into_iter().flatten() {
|
||||
let mut operator = Vec::new();
|
||||
for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() {
|
||||
let snbt = v.as_str().unwrap_or("-");
|
||||
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
|
||||
items.push(stack);
|
||||
operator.push(only.contains(&(i as u64)));
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
@@ -223,34 +265,35 @@ impl Creative {
|
||||
};
|
||||
for (i, stack) in items.iter().enumerate() {
|
||||
let (namespace, path) = split_id(&java_lower(&stack.item));
|
||||
search_index.push(SearchEntry {
|
||||
let entry = SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
lines: strings(&t["lines"][i]),
|
||||
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
|
||||
});
|
||||
};
|
||||
search.push((stack.clone(), entry, operator[i]));
|
||||
}
|
||||
search_items = items.clone();
|
||||
}
|
||||
if t["visible"] == false {
|
||||
continue;
|
||||
}
|
||||
let icon = t["icon"]
|
||||
.as_str()
|
||||
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: kind == "HOTBAR",
|
||||
tabs.push(Exported {
|
||||
tab: Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: kind == "HOTBAR",
|
||||
},
|
||||
category: kind == "CATEGORY",
|
||||
operator,
|
||||
});
|
||||
}
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
Ok(Self::new(tabs, search))
|
||||
}
|
||||
/// The old export: item ids per tab, no components, tooltips or tags.
|
||||
fn from_legacy(raw: &Value) -> Result<Self> {
|
||||
@@ -272,34 +315,39 @@ impl Creative {
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: false,
|
||||
tabs.push(Exported {
|
||||
operator: vec![false; items.len()],
|
||||
tab: Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: false,
|
||||
},
|
||||
category: false,
|
||||
});
|
||||
}
|
||||
let search_index = search_items
|
||||
.iter()
|
||||
let search = search_items
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let (namespace, path) = split_id(&java_lower(&s.item));
|
||||
SearchEntry {
|
||||
let entry = SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
(s, entry, false)
|
||||
})
|
||||
.collect();
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
Ok(Self::new(tabs, search))
|
||||
}
|
||||
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
|
||||
Self {
|
||||
tabs,
|
||||
fn new(exported: Vec<Exported>, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self {
|
||||
let mut creative = Self {
|
||||
tabs: Vec::new(),
|
||||
selected: 0,
|
||||
scroll: 0.0,
|
||||
scrolling: false,
|
||||
@@ -310,10 +358,81 @@ impl Creative {
|
||||
search.text_color = u32::MAX;
|
||||
search
|
||||
},
|
||||
search_items,
|
||||
search_index,
|
||||
search_items: Vec::new(),
|
||||
search_index: Vec::new(),
|
||||
exported,
|
||||
exported_search,
|
||||
permissions: false,
|
||||
shown: Mutex::new(None),
|
||||
};
|
||||
creative.rebuild();
|
||||
creative
|
||||
}
|
||||
/// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator
|
||||
/// tab's display items generator lists its stacks only with them
|
||||
/// (`hasPermissions`), and the search tab with it; a category tab is
|
||||
/// shown with a stack (`CreativeModeTab.shouldDisplay`).
|
||||
fn rebuild(&mut self) {
|
||||
let permissions = self.permissions;
|
||||
let keep = |operator: bool| permissions || !operator;
|
||||
self.tabs = self
|
||||
.exported
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let items: Vec<ItemStack> = (e.tab.items.iter().zip(&e.operator))
|
||||
.filter(|(_, o)| keep(**o))
|
||||
.map(|(s, _)| s.clone())
|
||||
.collect();
|
||||
(!e.category || !items.is_empty()).then(|| Tab {
|
||||
items,
|
||||
..e.tab.header()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(self.search_items, self.search_index) = self
|
||||
.exported_search
|
||||
.iter()
|
||||
.filter(|(_, _, o)| keep(*o))
|
||||
.map(|(s, e, _)| (s.clone(), e.clone()))
|
||||
.unzip();
|
||||
self.forget_items();
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
|
||||
/// constructor and `containerTick` run with `hasPermissions`
|
||||
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
|
||||
/// when that changed, the tabs are built again. A selected tab left
|
||||
/// without stacks (the operator tab) gives way to the default tab; any
|
||||
/// other keeps its first row (`refreshCurrentTabContents`). True when the
|
||||
/// tabs were rebuilt.
|
||||
pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool {
|
||||
if permissions == self.permissions {
|
||||
return false;
|
||||
}
|
||||
let selected = self.tab().id.clone();
|
||||
let row = self.first_row(names);
|
||||
self.permissions = permissions;
|
||||
self.rebuild();
|
||||
match self.tabs.iter().position(|t| t.id == selected) {
|
||||
Some(tab) => {
|
||||
self.selected = tab;
|
||||
// ItemPickerMenu.getScrollForRowIndex.
|
||||
let hidden = self.hidden_rows(names);
|
||||
self.scroll = if hidden > 0 {
|
||||
(row as f32 / hidden as f32).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
None => {
|
||||
// CreativeModeTabs.getDefaultTab.
|
||||
let default = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|t| t.id == "minecraft:building_blocks");
|
||||
self.select(default.unwrap_or(0));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Where a stack comes in the creative tabs (the search tab lists them
|
||||
/// all, in tab order): the same stack's place, else its item's first.
|
||||
@@ -327,20 +446,7 @@ impl Creative {
|
||||
}
|
||||
/// The tabs without their stacks: what the screen draws of them each frame.
|
||||
pub fn tab_headers(&self) -> Vec<Tab> {
|
||||
self.tabs
|
||||
.iter()
|
||||
.map(|t| Tab {
|
||||
id: t.id.clone(),
|
||||
title: t.title.clone(),
|
||||
icon: t.icon.clone(),
|
||||
items: Vec::new(),
|
||||
top: t.top,
|
||||
column: t.column,
|
||||
search: t.search,
|
||||
inventory: t.inventory,
|
||||
hotbar: t.hotbar,
|
||||
})
|
||||
.collect()
|
||||
self.tabs.iter().map(Tab::header).collect()
|
||||
}
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
@@ -601,7 +707,7 @@ impl Creative {
|
||||
pub fn tab_position(tab: &Tab) -> (f32, f32) {
|
||||
let mut x = tab.column as f32 * 27.0;
|
||||
if tab.column >= 5 {
|
||||
// Right-aligned tabs (search, inventory).
|
||||
// Right-aligned tabs (search, operator, inventory).
|
||||
x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0;
|
||||
}
|
||||
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
|
||||
@@ -1083,6 +1189,84 @@ mod tests {
|
||||
assert_eq!(c.search.value, "");
|
||||
}
|
||||
|
||||
/// An export built with permissions: the operator tab's stacks, and their
|
||||
/// place at the end of the search tab, only with them.
|
||||
fn with_operator_tab() -> Creative {
|
||||
let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}");
|
||||
let raw = serde_json::json!({"schema": 3, "tabs": [
|
||||
{"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks",
|
||||
"icon": stack("bricks"), "visible": true, "items": [stack("stone")]},
|
||||
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
|
||||
"icon": stack("command_block"), "visible": false,
|
||||
"items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]},
|
||||
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
|
||||
"icon": stack("compass"), "visible": true,
|
||||
"items": [stack("stone"), stack("command_block"), stack("barrier")],
|
||||
"lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []],
|
||||
"permissions_only": [1, 2]},
|
||||
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
|
||||
"icon": stack("chest"), "visible": true, "items": []}
|
||||
]});
|
||||
Creative::from_json(&raw).unwrap()
|
||||
}
|
||||
|
||||
fn tab_ids(c: &Creative) -> Vec<&str> {
|
||||
c.tabs.iter().map(|t| t.id.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_stacks_stay_out_without_permissions() {
|
||||
let c = with_operator_tab();
|
||||
let names = Names::default();
|
||||
assert_eq!(
|
||||
tab_ids(&c),
|
||||
[
|
||||
"minecraft:building_blocks",
|
||||
"minecraft:search",
|
||||
"minecraft:inventory"
|
||||
]
|
||||
);
|
||||
assert_eq!(c.search_items().len(), 1);
|
||||
assert!(c.search_results("command", &names).is_empty());
|
||||
assert_eq!(c.search_results("", &names), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_tab_comes_and_goes_with_permissions() {
|
||||
let mut c = with_operator_tab();
|
||||
let names = Names::default();
|
||||
assert!(!c.set_permissions(false, &names));
|
||||
assert!(c.set_permissions(true, &names));
|
||||
assert!(!c.set_permissions(true, &names));
|
||||
assert_eq!(
|
||||
tab_ids(&c),
|
||||
[
|
||||
"minecraft:building_blocks",
|
||||
"minecraft:op_blocks",
|
||||
"minecraft:search",
|
||||
"minecraft:inventory"
|
||||
]
|
||||
);
|
||||
let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect();
|
||||
assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]);
|
||||
assert_eq!(c.search_results("command", &names), [1]);
|
||||
assert_eq!(c.search_results("", &names), [0, 1, 2]);
|
||||
// The search tab keeps its place across the rebuild.
|
||||
c.select(2);
|
||||
c.search.set_value("block");
|
||||
assert!(c.set_permissions(false, &names));
|
||||
assert!(c.tab().search);
|
||||
assert_eq!(c.search.value, "block");
|
||||
assert!(c.items(&names).is_empty());
|
||||
// The selected operator tab is left empty: the default tab instead.
|
||||
c.set_permissions(true, &names);
|
||||
c.select(1);
|
||||
assert_eq!(c.items(&names).len(), 2);
|
||||
c.set_permissions(false, &names);
|
||||
assert_eq!(c.tab().id, "minecraft:building_blocks");
|
||||
assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
|
||||
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! (the entity goes through on the next tick), and
|
||||
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
|
||||
use crate::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use crate::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// The End gateway travel of one entity.
|
||||
@@ -65,6 +66,35 @@ pub fn swept_gateway(
|
||||
found
|
||||
}
|
||||
|
||||
/// The End gateway a thrown ender pearl passed through on its last move, from
|
||||
/// where it started the tick to where it is (`Entity.checkInsideBlocks` from
|
||||
/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles
|
||||
/// are not looked at.
|
||||
pub fn pearl_gateway(
|
||||
pearl: &Projectile,
|
||||
is_gateway: impl FnMut(BlockPos) -> bool,
|
||||
) -> Option<BlockPos> {
|
||||
if !matches!(pearl.kind, ProjectileKind::EnderPearl) {
|
||||
return None;
|
||||
}
|
||||
let size = pearl.kind.size() as f32;
|
||||
swept_gateway(pearl.previous, pearl.position, size, size, is_gateway)
|
||||
}
|
||||
|
||||
/// A thrown ender pearl at its gateway's exit `at`
|
||||
/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a
|
||||
/// `TeleportTransition` with no motion, rotation 0 and nothing relative).
|
||||
/// It falls from there and takes its owner where it lands.
|
||||
pub fn place_pearl(pearl: &mut Projectile, at: Vec3) {
|
||||
pearl.position = at;
|
||||
pearl.previous = at;
|
||||
pearl.velocity = Vec3::ZERO;
|
||||
pearl.yaw = 0.0;
|
||||
pearl.pitch = 0.0;
|
||||
// A server tells its clients of the jump at once.
|
||||
pearl.needs_sync = true;
|
||||
}
|
||||
|
||||
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
|
||||
/// lands at a gateway's exit.
|
||||
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
|
||||
@@ -150,6 +180,75 @@ mod tests {
|
||||
assert_eq!(traveller.travel(false), None);
|
||||
}
|
||||
|
||||
/// A world with no blocks in the way and nothing to hit: the gateway
|
||||
/// block has no collision shape, so a projectile flies into it.
|
||||
struct Open;
|
||||
impl crate::projectiles::ProjectileWorld for Open {
|
||||
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
None
|
||||
}
|
||||
fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool {
|
||||
false
|
||||
}
|
||||
fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// The ticks a projectile flies, thrown east at the gateway, until its
|
||||
/// move goes through one (`None` if it never does in 5 ticks).
|
||||
fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option<BlockPos>) {
|
||||
let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None);
|
||||
shot.velocity = at(1.5, 0.0, 0.0);
|
||||
for _ in 0..5 {
|
||||
shot.tick(&Open);
|
||||
if let Some(g) = pearl_gateway(&shot, is_gateway) {
|
||||
return (shot, Some(g));
|
||||
}
|
||||
}
|
||||
(shot, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() {
|
||||
let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl);
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
// ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside
|
||||
// takes it in, and it goes through on the next tick.
|
||||
assert_eq!(found, Some(gateway));
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter(gateway);
|
||||
assert_eq!(traveller.travel(true), Some(gateway));
|
||||
// getPortalDestination: at the exit, no motion and rotation 0.
|
||||
let exit = bottom_center([8, 100, 0]);
|
||||
place_pearl(&mut pearl, exit);
|
||||
assert_eq!(pearl.position, exit);
|
||||
assert_eq!(pearl.previous, exit);
|
||||
assert_eq!(pearl.velocity, Vec3::ZERO);
|
||||
assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0));
|
||||
// It falls straight down from the exit (ThrowableProjectile gravity,
|
||||
// then inertia), where it lands and takes its owner along.
|
||||
pearl.tick(&Open);
|
||||
assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5));
|
||||
assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9);
|
||||
// The pearl is out of the gateway: it does not go back at once.
|
||||
assert_eq!(traveller.travel(true), None);
|
||||
assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pearl_flying_past_the_gateway_stays_out_of_it() {
|
||||
// Thrown one block higher, over the gateway block.
|
||||
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None);
|
||||
pearl.velocity = at(1.5, 0.0, 0.0);
|
||||
for _ in 0..5 {
|
||||
pearl.tick(&Open);
|
||||
assert_eq!(pearl_gateway(&pearl, is_gateway), None);
|
||||
}
|
||||
// Only the pearl travels: a snowball on the same path is not taken in.
|
||||
assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
|
||||
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
|
||||
|
||||
@@ -83,10 +83,10 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
let layout = self.world.layout();
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
minimum_section_y: layout.min_y() >> 4,
|
||||
has_fluid: crate::chunk::every_section(layout),
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
|
||||
@@ -1986,6 +1986,8 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
|
||||
},
|
||||
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
|
||||
"minecraft:wind_charge" => ProjectileKind::WindCharge,
|
||||
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
|
||||
_ => return None,
|
||||
};
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
@@ -3419,6 +3421,20 @@ mod fireball_nbt_tests {
|
||||
(p.kind, p.acceleration),
|
||||
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
|
||||
);
|
||||
// `/summon` and a saved world give back both wind charges.
|
||||
for (id, kind) in [
|
||||
("minecraft:wind_charge", ProjectileKind::WindCharge),
|
||||
(
|
||||
"minecraft:breeze_wind_charge",
|
||||
ProjectileKind::BreezeWindCharge,
|
||||
),
|
||||
] {
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String(id.into())),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
]);
|
||||
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+370
-129
@@ -136,6 +136,7 @@ mod lan_stations;
|
||||
mod lan_trading;
|
||||
mod lan_vehicles;
|
||||
mod lan_waypoints;
|
||||
mod launch_api;
|
||||
mod leads;
|
||||
mod level_particles;
|
||||
mod levels;
|
||||
@@ -162,6 +163,7 @@ mod native_panel;
|
||||
mod native_panel_recipes;
|
||||
mod native_recipes;
|
||||
mod native_shaders;
|
||||
mod native_world_read;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
mod net_harness;
|
||||
@@ -189,6 +191,7 @@ mod pumpkin;
|
||||
mod raids;
|
||||
mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_camera;
|
||||
mod remote_chunks;
|
||||
mod remote_mob_data;
|
||||
mod remote_mob_particles;
|
||||
@@ -255,7 +258,6 @@ use horizon_client::{
|
||||
audio::Audio,
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, Fluid},
|
||||
chunk::{HEIGHT, MIN_Y},
|
||||
clock::FixedStep,
|
||||
collisions,
|
||||
containers::{BlockEntity, Providers},
|
||||
@@ -374,6 +376,16 @@ struct Options {
|
||||
server_thread: bool,
|
||||
/// What this Game executes; independent of its current server connection.
|
||||
execution_role: execution_role::ExecutionRole,
|
||||
/// `--fullscreen` (vanilla's): the window opens fullscreen whatever options.txt says,
|
||||
/// as vanilla's DisplayData does, without saving it there.
|
||||
fullscreen: bool,
|
||||
/// `--game-dir DIR`: the folder `read` makes the working directory (saves, options.txt...).
|
||||
game_dir: Option<PathBuf>,
|
||||
/// `--quickPlaySingleplayer`, checked once the game folder is known.
|
||||
quick_play: Option<String>,
|
||||
session_file: Option<String>,
|
||||
/// `--session-stdin`: the session file's JSON comes on standard input instead.
|
||||
session_stdin: bool,
|
||||
}
|
||||
/// `--in-frame`, `--no-server-thread` and `HORIZON_SERVER_THREAD=0` are deprecated:
|
||||
/// the in-frame world is unmaintained, gets no fixes and will be removed soon.
|
||||
@@ -394,7 +406,65 @@ impl Options {
|
||||
)
|
||||
})
|
||||
}
|
||||
/// The command line, then what it asks of the process: the game folder, the account.
|
||||
/// A bad command line is a [`launch_api::UsageError`] (exit code 2, no crash report).
|
||||
fn read() -> Result<Option<Self>> {
|
||||
let Some(mut o) = Self::parse(std::env::args().skip(1))
|
||||
.map_err(|e| launch_api::UsageError(e.to_string()))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
// The launcher's account: online-mode servers need it. A bad file
|
||||
// only leaves the game offline.
|
||||
let session = if o.session_stdin {
|
||||
let mut text = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| horizon_client::auth::AuthSession::parse(&text))
|
||||
.map(Some)
|
||||
} else {
|
||||
o.session_file
|
||||
.take()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|path| horizon_client::auth::AuthSession::load(path.as_ref()))
|
||||
.transpose()
|
||||
};
|
||||
match session {
|
||||
Ok(Some(session)) => {
|
||||
o.player_name = Some(session.name.clone());
|
||||
horizon_client::auth::set_current(session);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => eprintln!("Horizon: not signed in: {e}"),
|
||||
}
|
||||
if let Some(dir) = o.game_dir.take() {
|
||||
// Paths given on the command line stay relative to where the game was started.
|
||||
o.resources = std::path::absolute(&o.resources)?;
|
||||
o.report = o.report.map(std::path::absolute).transpose()?;
|
||||
o.control = o.control.map(std::path::absolute).transpose()?;
|
||||
std::fs::create_dir_all(&dir)
|
||||
.and_then(|()| std::env::set_current_dir(&dir))
|
||||
.map_err(|e| format!("game folder {}: {e}", dir.display()))?;
|
||||
}
|
||||
// Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a
|
||||
// world that exists opens; else the title screen. --world also creates one.
|
||||
if let Some(world) = o.quick_play.take() {
|
||||
if std::env::current_dir()?
|
||||
.join("saves")
|
||||
.join(&world)
|
||||
.join("level.dat")
|
||||
.is_file()
|
||||
{
|
||||
o.world = Some(world);
|
||||
} else {
|
||||
eprintln!("Quick play: there is no world {world} in saves/");
|
||||
}
|
||||
}
|
||||
Ok(Some(o))
|
||||
}
|
||||
/// The command line alone: no file is read and nothing changes. `None` for `--help` and
|
||||
/// `--launch-api`, which print and end the run.
|
||||
fn parse(mut args: impl Iterator<Item = String>) -> Result<Option<Self>> {
|
||||
let sibling = std::env::current_exe()?
|
||||
.parent()
|
||||
.ok_or("missing executable directory")?
|
||||
@@ -442,18 +512,27 @@ impl Options {
|
||||
on
|
||||
}),
|
||||
execution_role: execution_role::ExecutionRole::Client,
|
||||
fullscreen: false,
|
||||
game_dir: None,
|
||||
quick_play: None,
|
||||
session_file: std::env::var("HORIZON_SESSION_FILE").ok(),
|
||||
session_stdin: false,
|
||||
};
|
||||
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
"--launch-api" => {
|
||||
println!("{}", launch_api::reply(&horizon_client::crash::version()));
|
||||
return Ok(None);
|
||||
}
|
||||
"--fullscreen" => o.fullscreen = true,
|
||||
"--session-stdin" => o.session_stdin = true,
|
||||
"--dedicated" => {
|
||||
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
|
||||
o.dedicated = Some(args.by_ref().collect());
|
||||
@@ -475,92 +554,92 @@ impl Options {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| format!("missing value after {arg}"))?;
|
||||
match arg.as_str() {
|
||||
"--resources" => o.resources = value.into(),
|
||||
"--render-distance" | "--radius" => {
|
||||
o.distance = value.parse()?;
|
||||
o.distance_override = Some(o.distance);
|
||||
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
|
||||
if !(2..=max).contains(&o.distance) {
|
||||
return Err(format!("render distance must be 2..{max}").into());
|
||||
// A launcher shows the error to the player: it names the option.
|
||||
let read = || -> Result<()> {
|
||||
match arg.as_str() {
|
||||
"--resources" => o.resources = value.into(),
|
||||
"--render-distance" | "--radius" => {
|
||||
o.distance = value.parse()?;
|
||||
o.distance_override = Some(o.distance);
|
||||
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
|
||||
if !(2..=max).contains(&o.distance) {
|
||||
return Err(format!("render distance must be 2..{max}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
"--seed" => {
|
||||
o.seed = value.parse().unwrap_or_else(|_| {
|
||||
// Text seeds hash like String.hashCode.
|
||||
value
|
||||
.encode_utf16()
|
||||
.fold(0i32, |h, c| {
|
||||
h.wrapping_mul(31).wrapping_add(i32::from(c))
|
||||
})
|
||||
.into()
|
||||
})
|
||||
}
|
||||
"--frames" => o.frames = Some(value.parse()?),
|
||||
"--world-frames" => {
|
||||
o.world_frames = Some(value.parse()?);
|
||||
// A frame-limited run in every other respect; --timeout-seconds still applies.
|
||||
o.frames.get_or_insert(u64::MAX);
|
||||
}
|
||||
"--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?),
|
||||
"--smoke-report" => o.report = Some(value.into()),
|
||||
"--screenshot-after" => o.screenshot_after = Some(value.parse()?),
|
||||
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
|
||||
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
|
||||
"--world" => o.world = Some(value),
|
||||
// Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a
|
||||
// world that exists opens; else the title screen. --world also creates one.
|
||||
"--quickPlaySingleplayer" => {
|
||||
let folder = std::env::current_dir()?.join("saves").join(&value);
|
||||
if folder.join("level.dat").is_file() {
|
||||
o.world = Some(value);
|
||||
} else {
|
||||
eprintln!("Quick play: there is no world {value} in saves/");
|
||||
"--seed" => {
|
||||
o.seed = value.parse().unwrap_or_else(|_| {
|
||||
// Text seeds hash like String.hashCode.
|
||||
value
|
||||
.encode_utf16()
|
||||
.fold(0i32, |h, c| {
|
||||
h.wrapping_mul(31).wrapping_add(i32::from(c))
|
||||
})
|
||||
.into()
|
||||
})
|
||||
}
|
||||
}
|
||||
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
|
||||
"--benchmark" => {
|
||||
if horizon_client::benchmark::scenes(&value).is_none() {
|
||||
return Err(format!(
|
||||
"--frames" => o.frames = Some(value.parse()?),
|
||||
"--world-frames" => {
|
||||
o.world_frames = Some(value.parse()?);
|
||||
// A frame-limited run in every other respect; --timeout-seconds still applies.
|
||||
o.frames.get_or_insert(u64::MAX);
|
||||
}
|
||||
"--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?),
|
||||
"--smoke-report" => o.report = Some(value.into()),
|
||||
"--screenshot-after" => o.screenshot_after = Some(value.parse()?),
|
||||
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
|
||||
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
|
||||
// Vanilla opens only a level id that is one folder of saves/. --world, for
|
||||
// harnesses, also takes a path (tools/bundle_tooltip_e2e.py).
|
||||
"--world" => o.world = Some(value),
|
||||
"--quickPlaySingleplayer" => {
|
||||
let mut parts = std::path::Path::new(&value).components();
|
||||
if !matches!(
|
||||
(parts.next(), parts.next()),
|
||||
(Some(std::path::Component::Normal(_)), None)
|
||||
) {
|
||||
return Err("not a folder name of saves/".into());
|
||||
}
|
||||
o.quick_play = Some(value);
|
||||
}
|
||||
"--game-dir" | "--gameDir" => o.game_dir = Some(value.into()),
|
||||
// main has made it the game home already (logging::set_game_home).
|
||||
"--home" => {}
|
||||
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
|
||||
"--benchmark" => {
|
||||
if horizon_client::benchmark::scenes(&value).is_none() {
|
||||
return Err(format!(
|
||||
"unknown benchmark {value}: standard, village, forest, ocean or mountain"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
o.benchmark = Some(value);
|
||||
}
|
||||
o.benchmark = Some(value);
|
||||
"--server" | "--quickPlayMultiplayer" => o.server = Some(value),
|
||||
"--name" => o.player_name = Some(value),
|
||||
"--session-file" => o.session_file = Some(value),
|
||||
"--control-socket" => o.control = Some(value.into()),
|
||||
"--time" => o.time = Some(value.parse()?),
|
||||
"--weather" => o.weather = Some(value),
|
||||
"--dimension" => {
|
||||
o.dimension = Some(if value.contains(':') {
|
||||
value
|
||||
} else {
|
||||
format!("minecraft:{value}")
|
||||
})
|
||||
}
|
||||
"--look" => {
|
||||
let (y, p) =
|
||||
value.split_once(',').ok_or("--look takes YAW,PITCH")?;
|
||||
o.look = Some((y.parse()?, p.parse()?));
|
||||
}
|
||||
_ => return Err("unknown option".into()),
|
||||
}
|
||||
"--server" | "--quickPlayMultiplayer" => o.server = Some(value),
|
||||
"--name" => o.player_name = Some(value),
|
||||
"--session-file" => session_file = Some(value),
|
||||
"--control-socket" => o.control = Some(value.into()),
|
||||
"--time" => o.time = Some(value.parse()?),
|
||||
"--weather" => o.weather = Some(value),
|
||||
"--dimension" => {
|
||||
o.dimension = Some(if value.contains(':') {
|
||||
value
|
||||
} else {
|
||||
format!("minecraft:{value}")
|
||||
})
|
||||
}
|
||||
"--look" => {
|
||||
let (y, p) = value.split_once(',').ok_or("--look takes YAW,PITCH")?;
|
||||
o.look = Some((y.parse()?, p.parse()?));
|
||||
}
|
||||
_ => return Err(format!("unknown option {arg}").into()),
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
read().map_err(|e| format!("{arg}: {e}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The launcher's account: online-mode servers need it. A bad file
|
||||
// only leaves the game offline.
|
||||
if let Some(path) = session_file.filter(|p| !p.is_empty()) {
|
||||
match horizon_client::auth::AuthSession::load(path.as_ref()) {
|
||||
Ok(session) => {
|
||||
o.player_name = Some(session.name.clone());
|
||||
horizon_client::auth::set_current(session);
|
||||
}
|
||||
Err(e) => eprintln!("Horizon: not signed in: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(Some(o))
|
||||
}
|
||||
}
|
||||
@@ -685,6 +764,8 @@ struct GameLevel<'w> {
|
||||
/// `Level.isClientSide`: a client's copy of the level (a guest of a LAN
|
||||
/// world, a window on its server thread, a dedicated server's client).
|
||||
client_side: bool,
|
||||
/// Only the integrated or dedicated server dispatches native observations.
|
||||
native_authoritative: bool,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -871,6 +952,22 @@ impl GameLevel<'_> {
|
||||
}
|
||||
}
|
||||
impl LevelAccess for GameLevel<'_> {
|
||||
fn observe_block_changed(&self, pos: BlockPos, old: BlockStateId, new: BlockStateId) {
|
||||
if self.native_authoritative {
|
||||
self.native_block_changed(self.dimension, pos, old, new);
|
||||
}
|
||||
}
|
||||
fn observe_neighbor_changed(
|
||||
&self,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
changed: horizon_world::level::BlockId,
|
||||
moved: bool,
|
||||
) {
|
||||
if self.native_authoritative {
|
||||
self.native_neighbor_changed(self.dimension, pos, state, changed, moved);
|
||||
}
|
||||
}
|
||||
fn is_client_side(&self) -> bool {
|
||||
self.client_side
|
||||
}
|
||||
@@ -998,7 +1095,10 @@ impl LevelAccess for GameLevel<'_> {
|
||||
self.world.block(pos.x, pos.y, pos.z)
|
||||
}
|
||||
fn set_raw(&mut self, pos: BlockPos, state: BlockStateId) -> Option<BlockStateId> {
|
||||
if !self.world.loaded(pos.x, pos.z) || pos.y < MIN_Y || pos.y >= MIN_Y + HEIGHT {
|
||||
if !self.world.loaded(pos.x, pos.z)
|
||||
|| pos.y < self.world.bottom_y()
|
||||
|| pos.y >= self.world.top_y()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let previous = self.world.block(pos.x, pos.y, pos.z);
|
||||
@@ -1009,19 +1109,11 @@ impl LevelAccess for GameLevel<'_> {
|
||||
self.world.loaded(pos.x, pos.z)
|
||||
}
|
||||
fn min_y(&self) -> i32 {
|
||||
if self.dimension != LevelDimension::Overworld {
|
||||
0
|
||||
} else {
|
||||
MIN_Y
|
||||
}
|
||||
self.world.shape.min_y
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
// Exclusive, as LevelAccess::max_y documents.
|
||||
if self.dimension != LevelDimension::Overworld {
|
||||
256
|
||||
} else {
|
||||
MIN_Y + HEIGHT
|
||||
}
|
||||
self.world.shape.max_y()
|
||||
}
|
||||
fn random(&mut self) -> &mut horizon_core::random::LegacyRandom {
|
||||
&mut self.world.random
|
||||
@@ -1161,6 +1253,13 @@ impl LevelAccess for GameLevel<'_> {
|
||||
// player is this client's own: the block's `playSound(null, ...)`
|
||||
// (a piston's move, a note) reaches it from the server instead.
|
||||
LevelEvent::Sound { .. } if self.client_side => {}
|
||||
// NoteBlock.triggerEvent plays the head's sound with a null
|
||||
// player, so only the server's level plays it; a guest or a
|
||||
// remote client hears it from the sound packet.
|
||||
LevelEvent::BlockEvent {
|
||||
kind: "note_custom_sound",
|
||||
..
|
||||
} if self.client_side => {}
|
||||
// Block.popResource of the block's own item (tall flowers, flower beds).
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
@@ -1522,6 +1621,9 @@ struct Game<'a> {
|
||||
pending_entry: Option<PendingWorldEntry>,
|
||||
world_loader: Option<world_loading::WorldLoader>,
|
||||
pending_pack_reload: bool,
|
||||
/// A pack reload staged on a worker (`LoadingOverlay`): the frame draws
|
||||
/// its progress until `drive_pack_reload` applies it.
|
||||
pack_reload: Option<packs::PackReload>,
|
||||
/// A server's resource packs (game/server_packs.rs).
|
||||
server_packs: server_packs::ServerPackState,
|
||||
/// The link a chat click asks to open (`ConfirmLinkScreen`).
|
||||
@@ -1663,6 +1765,9 @@ struct Game<'a> {
|
||||
/// Controllers (Horizon's own).
|
||||
pad: pad::Pads,
|
||||
frames: u64,
|
||||
/// The glint clock and options, read once at the start of each frame
|
||||
/// (game/item_draw.rs).
|
||||
glint_frame: horizon_client::item_render::GlintFrame,
|
||||
/// `item_level` for one frame, game time, day time and dimension.
|
||||
item_level_cache: std::cell::RefCell<
|
||||
Option<(
|
||||
@@ -1794,6 +1899,9 @@ struct Game<'a> {
|
||||
/// LocalPlayer (joining, respawning, changing dimension) starts at 0.
|
||||
sway: horizon_client::first_person::HandSway,
|
||||
visible_hands: [(usize, Option<ItemStack>); 2],
|
||||
/// Whether the main and off hand's slots were set to a new stack that
|
||||
/// the hand doesn't show yet (`Game::hand_stack_replaced`).
|
||||
hands_replaced: [bool; 2],
|
||||
/// `Minecraft`'s attack handling, attack clicks since the last tick and
|
||||
/// the attack strength tickers the hand reads.
|
||||
clicks: horizon_client::first_person::Clicks,
|
||||
@@ -2170,25 +2278,15 @@ impl Dim {
|
||||
}
|
||||
/// `Level.getMinY`: the dimension type's minimum y.
|
||||
fn min_y(self) -> i32 {
|
||||
match self {
|
||||
Self::Overworld => MIN_Y,
|
||||
Self::Nether | Self::End => 0,
|
||||
}
|
||||
self.shape().min_y
|
||||
}
|
||||
/// `DimensionType.logicalHeight`.
|
||||
fn logical_height(self) -> i32 {
|
||||
match self {
|
||||
Self::Overworld => HEIGHT,
|
||||
Self::Nether => 128,
|
||||
Self::End => 256,
|
||||
}
|
||||
self.shape().logical_height
|
||||
}
|
||||
/// `Level.getMaxY`: the dimension type's minimum y plus height, less one.
|
||||
fn max_y(self) -> i32 {
|
||||
match self {
|
||||
Self::Overworld => MIN_Y + HEIGHT - 1,
|
||||
Self::Nether | Self::End => 255,
|
||||
}
|
||||
self.shape().max_y() - 1
|
||||
}
|
||||
}
|
||||
fn facing_offset(facing: &str) -> [i32; 3] {
|
||||
@@ -2254,6 +2352,14 @@ impl Game<'_> {
|
||||
/// crosshair target (blocks, fluids, entities, mod raycasts) is found
|
||||
/// along this ray, so a tracked controller can stand in for it.
|
||||
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
|
||||
// Minecraft.pick: from the camera entity's eyes along its view
|
||||
// (LivingEntity.getViewYRot: the head's yaw).
|
||||
if let Some(m) = self.camera_mob() {
|
||||
return Some((
|
||||
[m.position.x, m.eye_y(), m.position.z],
|
||||
view_vector(m.head_yaw, m.head_pitch),
|
||||
));
|
||||
}
|
||||
Some((self.eye_position()?, view_vector(self.yaw, self.pitch)))
|
||||
}
|
||||
fn reach(&self) -> f64 {
|
||||
@@ -3193,6 +3299,7 @@ impl Game<'_> {
|
||||
random_tick_sections: &mut self.random_tick_sections,
|
||||
lid_openers: &self.lid_openers,
|
||||
client_side: self.remote.is_some(),
|
||||
native_authoritative: self.serving.is_some() || self.options.dedicated.is_some(),
|
||||
};
|
||||
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
|
||||
self.flush_spill();
|
||||
@@ -3221,6 +3328,73 @@ impl Game<'_> {
|
||||
}
|
||||
self.process_level_events()
|
||||
}
|
||||
/// `AbstractCandleBlock.extinguish`: the smoke from each wick, at
|
||||
/// CandleBlock.PARTICLE_OFFSETS (sixteenths of a block), rising 0.1.
|
||||
fn candle_smoke(&mut self, pos: [i32; 3], state: BlockStateId) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let candles = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("candles")?.parse::<usize>().ok())
|
||||
.unwrap_or(1);
|
||||
const OFFSETS: [&[[u8; 3]]; 4] = [
|
||||
&[[8, 8, 8]],
|
||||
&[[6, 7, 8], [10, 8, 7]],
|
||||
&[[8, 5, 10], [6, 7, 8], [9, 8, 7]],
|
||||
&[[7, 5, 9], [10, 7, 9], [6, 7, 6], [9, 8, 6]],
|
||||
];
|
||||
for o in OFFSETS[candles.clamp(1, 4) - 1] {
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::Smoke),
|
||||
[
|
||||
f64::from(pos[0]) + f64::from(o[0]) / 16.0,
|
||||
f64::from(pos[1]) + f64::from(o[1]) / 16.0,
|
||||
f64::from(pos[2]) + f64::from(o[2]) / 16.0,
|
||||
],
|
||||
[0.0, 0.1, 0.0],
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `LeverBlock.makeParticle` at full scale: a powered lever puffs one red
|
||||
/// dust on the clicking client (`LeverBlock.useWithoutItem`).
|
||||
fn lever_puff(&mut self, pos: [i32; 3]) {
|
||||
// The server's side of a guest's pull draws nothing.
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let Some(registry_state) = self.pack.assets.registry.state(state) else {
|
||||
return;
|
||||
};
|
||||
let prop = |key: &str| registry_state.properties.get(key).map(String::as_str);
|
||||
if prop("powered") != Some("true") {
|
||||
return;
|
||||
}
|
||||
// FACING is horizontal; getConnectedDirection is the face's direction.
|
||||
let facing = match prop("facing") {
|
||||
Some("north") => [0, 0, -1],
|
||||
Some("south") => [0, 0, 1],
|
||||
Some("west") => [-1, 0, 0],
|
||||
Some("east") => [1, 0, 0],
|
||||
_ => return,
|
||||
};
|
||||
let connected = match prop("face") {
|
||||
Some("ceiling") => [0, -1, 0],
|
||||
Some("floor") => [0, 1, 0],
|
||||
_ => facing,
|
||||
};
|
||||
let at = |base: i32, i: usize| {
|
||||
f64::from(base) + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
|
||||
};
|
||||
let p = [at(pos[0], 0), at(pos[1], 1), at(pos[2], 2)];
|
||||
self.particles.add(
|
||||
&horizon_client::particles::ParticleOptions::dust([1.0, 0.0, 0.0], 1.0),
|
||||
p,
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
/// Track which chunks are ticking (Ticks needs chunk load/unload).
|
||||
fn sync_tick_chunks(&mut self) {
|
||||
if !self.tick_chunks.synchronize(&self.world.chunks) {
|
||||
@@ -3958,6 +4132,35 @@ impl Game<'_> {
|
||||
[note / 24.0, 0.0, 0.0],
|
||||
);
|
||||
}
|
||||
// NoteBlock.getCustomSoundId: the skull above's note block sound,
|
||||
// played at volume 3 (vanilla's pitch for a head is 1).
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
kind: "note_custom_sound",
|
||||
..
|
||||
} => {
|
||||
if let Some(BlockEntity::Skull(skull)) =
|
||||
self.world.block_entities.get(&(pos.x, pos.y + 1, pos.z))
|
||||
&& let Some(sound) = skull.note_block_sound.as_deref()
|
||||
{
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 0.5,
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
self.audio.play(sound, Some(at), 3.0, 1.0);
|
||||
}
|
||||
}
|
||||
// `level.addParticle`: only the local player's own click draws it.
|
||||
LevelEvent::BlockEvent {
|
||||
kind: "candle_smoke",
|
||||
..
|
||||
} if self.lan_acting.is_some() => {}
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
state,
|
||||
kind: "candle_smoke",
|
||||
} => self.candle_smoke([pos.x, pos.y, pos.z], state),
|
||||
LevelEvent::GrowTree { .. }
|
||||
| LevelEvent::PlaceFeature { .. }
|
||||
| LevelEvent::BlockEvent { .. } => {}
|
||||
@@ -3983,10 +4186,6 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if horizon_client::command_block::Mode::of_block(&name).is_some() {
|
||||
// `ServerPlayer.openCommandBlock` sends the block entity's data
|
||||
// (`ClientboundBlockEntityDataPacket`) so the opening client shows
|
||||
// the stored command, not its stale copy.
|
||||
self.mark_block_entity_updated(key);
|
||||
self.open_command_block(key);
|
||||
return;
|
||||
}
|
||||
@@ -4151,7 +4350,7 @@ impl Game<'_> {
|
||||
// FallingBlockEntity.tick: off the ground, it gives up after 600
|
||||
// ticks (100 outside the world) and drops itself as an item.
|
||||
let y = next.y.floor() as i32;
|
||||
let outside = y <= horizon_client::chunk::MIN_Y || y >= horizon_client::chunk::MIN_Y + 384;
|
||||
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
|
||||
if !on_ground && (f.age > 100 && outside || f.age > 600) {
|
||||
return Ok(if f.drop_item && entity_drops {
|
||||
FallStep::Dropped(next)
|
||||
@@ -4444,6 +4643,16 @@ impl Game<'_> {
|
||||
xp,
|
||||
);
|
||||
}
|
||||
// TurtleEggBlock.playerDestroy: after the drops, one egg of a
|
||||
// mined stack is taken and the rest stay.
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.turtle_egg_destroyed_by_player(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
broken,
|
||||
);
|
||||
});
|
||||
}
|
||||
// IceBlock.playerDestroy: the ice melts back into water (the
|
||||
// #prevents_ice_melting tool tag holds only silk touch).
|
||||
@@ -4693,6 +4902,7 @@ impl Game<'_> {
|
||||
match &*name {
|
||||
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
|
||||
"minecraft:bell" => self.stats.custom("bell_ring", 1),
|
||||
"minecraft:lever" => self.lever_puff(hit.pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -5218,6 +5428,7 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
let block_break_speed = self.block_break_speed();
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let facts = self.res.facts().get(state);
|
||||
if facts.hardness < 0.0 {
|
||||
@@ -5232,6 +5443,7 @@ impl Game<'_> {
|
||||
let held = self.held();
|
||||
let mut speed = self.items().mining_speed(held, &facts.name);
|
||||
speed = self.modify_destroy_speed(speed);
|
||||
speed *= block_break_speed;
|
||||
let player = self.player.as_ref().ok_or("no player")?;
|
||||
if !player.ground.on_ground {
|
||||
speed /= 5.0;
|
||||
@@ -5295,8 +5507,8 @@ impl Game<'_> {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let mut y = MIN_Y + HEIGHT - 1;
|
||||
while y > MIN_Y {
|
||||
let mut y = self.world.top_y() - 1;
|
||||
while y > self.world.bottom_y() {
|
||||
let s = self.world.block(x, y, z);
|
||||
if !self.res.facts().is_air(s) {
|
||||
let f = self.res.facts().get(s);
|
||||
@@ -5469,6 +5681,9 @@ impl Game<'_> {
|
||||
self.tick_debug_keys()?;
|
||||
self.tick_debug_charts();
|
||||
self.tick_tutorial();
|
||||
if self.screen == Screen::Creative {
|
||||
self.refresh_creative_tabs();
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
return self.tick_remote();
|
||||
}
|
||||
@@ -5484,6 +5699,9 @@ impl Game<'_> {
|
||||
self.track_experience_display();
|
||||
// `/tick freeze` stops the world but not the player.
|
||||
let running = self.clock.world_runs();
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_lifecycle_server_tick_start(running);
|
||||
}
|
||||
// GameRenderer.tick: a wither's bar darkens the world, then fades;
|
||||
// the activated item counts down.
|
||||
self.boss_overlay.tick_darken(running);
|
||||
@@ -5791,7 +6009,7 @@ impl Game<'_> {
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
if player.position.y < f64::from(MIN_Y - 64) {
|
||||
if player.position.y < f64::from(self.world.bottom_y() - 64) {
|
||||
// Out of the world.
|
||||
let _ = player.set_health(0.0, false);
|
||||
}
|
||||
@@ -6679,6 +6897,14 @@ impl Game<'_> {
|
||||
}
|
||||
batch
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
|
||||
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
|
||||
/// as the screen's constructor and `containerTick` do.
|
||||
fn refresh_creative_tabs(&mut self) {
|
||||
let permissions = self.can_use_game_master_blocks() && self.settings.operator_items_tab;
|
||||
self.creative_screen
|
||||
.set_permissions(permissions, &self.pack.names);
|
||||
}
|
||||
fn creative_inventory_open(&self) -> bool {
|
||||
self.screen == Screen::Creative && self.creative_screen.tab().inventory
|
||||
}
|
||||
@@ -8911,8 +9137,9 @@ impl Game<'_> {
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
// ProjectileUtil.getEntityHitResult: the mob the player rides
|
||||
// shares its root vehicle and is never the hit.
|
||||
if !m.alive() || Some(m.id) == self.riding {
|
||||
// shares its root vehicle and is never the hit, nor is the
|
||||
// camera entity the pick starts from.
|
||||
if !m.alive() || Some(m.id) == self.riding || Some(m.id) == self.remote_camera_id() {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
|
||||
@@ -9731,6 +9958,7 @@ impl Game<'_> {
|
||||
let (streaming, save) =
|
||||
self.make_streaming(&self.world_root.clone(), target, self.level.seed)?;
|
||||
let mut world = World::new(self.res.facts().air, self.level.seed);
|
||||
world.shape = target.shape();
|
||||
world.fast_lava = target == Dim::Nether;
|
||||
world.game_time = self.world.game_time;
|
||||
world.day_time = self.world.day_time;
|
||||
@@ -11161,7 +11389,7 @@ impl Game<'_> {
|
||||
// renderArmWithItem: only the main hand's empty arm shows,
|
||||
// on the main arm (the left one with the main hand set to
|
||||
// left), and not while the player is invisible.
|
||||
if hand == Hand::Off || self.potions.effects.get("invisibility").is_some() {
|
||||
if hand == Hand::Off || self.own_invisible() {
|
||||
continue;
|
||||
}
|
||||
// renderPlayerArm
|
||||
@@ -11175,7 +11403,7 @@ impl Game<'_> {
|
||||
let hold = MapHold::of(hand, off_hand_empty);
|
||||
// renderOneHandedMap and renderTwoHandedMap: the arms under
|
||||
// the map, unless the player is invisible.
|
||||
if self.potions.effects.get("invisibility").is_none() {
|
||||
if !self.own_invisible() {
|
||||
for (arm, pose) in map_arm_poses(hold, left, self.pitch, inverse_height, attack)
|
||||
{
|
||||
self.first_person_arm(arm, to_world * pose, packed, &mut out);
|
||||
@@ -11351,7 +11579,7 @@ impl Game<'_> {
|
||||
/// `LevelExtractor.allChanged`: every loaded section is rebuilt.
|
||||
fn all_sections_changed(&mut self) {
|
||||
for &(x, z) in self.world.chunks.keys() {
|
||||
for sy in 0..horizon_client::chunk::HEIGHT / 16 {
|
||||
for sy in 0..self.world.layout().height() / 16 {
|
||||
self.world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
}
|
||||
@@ -12224,6 +12452,7 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
self.glint_frame = self.capture_glint_frame();
|
||||
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
@@ -12278,9 +12507,9 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
// A server pack's reload covers the frame with its progress
|
||||
// (`LoadingOverlay`) until `tick_server_packs` applies it.
|
||||
if let Some(reload) = &self.server_packs.reload {
|
||||
// A pack reload covers the frame with its progress
|
||||
// (`LoadingOverlay`) until `drive_pack_reload` applies it.
|
||||
if let Some(reload) = &self.pack_reload {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
horizon_client::level_loading::draw_progress(
|
||||
@@ -12634,8 +12863,8 @@ impl Game<'_> {
|
||||
debug_lines_on_top: &debug_lines_on_top,
|
||||
debug_crosshair: &debug_crosshair,
|
||||
hud: &hud,
|
||||
glint_alpha: self.settings.file.double("glintStrength") as f32,
|
||||
glint_ticks: self.glint_ticks(),
|
||||
glint_alpha: self.glint_frame.alpha,
|
||||
glint_ticks: self.glint_frame.scroll_ticks(),
|
||||
clouds: (!title
|
||||
&& self.dim == Dim::Overworld
|
||||
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
|
||||
@@ -12911,6 +13140,7 @@ impl Game<'_> {
|
||||
}
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.refresh_creative_tabs();
|
||||
self.creative_screen.reopen();
|
||||
self.refresh_saved_hotbars();
|
||||
} else {
|
||||
@@ -12946,6 +13176,8 @@ impl Game<'_> {
|
||||
// handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for
|
||||
// the off hand, then the main hand.
|
||||
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
|
||||
self.hand_stack_replaced(self.selected);
|
||||
self.hand_stack_replaced(shield::OFFHAND_SLOT);
|
||||
let (offhand, main) = (self.offhand.clone(), self.inventory[self.selected].clone());
|
||||
self.player_on_equip("offhand", main.as_ref(), offhand.as_ref());
|
||||
self.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
|
||||
@@ -13313,8 +13545,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt says so.
|
||||
let fullscreen = self.settings.fullscreen;
|
||||
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
|
||||
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = Window::default_attributes()
|
||||
@@ -13461,8 +13693,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
return;
|
||||
}
|
||||
// The pack reload's progress overlay takes no input
|
||||
// (`LoadingOverlay`): `start_pack_reload` released what was held.
|
||||
if self.server_packs.reload.is_some()
|
||||
// (`LoadingOverlay`): `spawn_pack_reload` released what was held.
|
||||
if self.pack_reload.is_some()
|
||||
&& !matches!(
|
||||
&event,
|
||||
WindowEvent::CloseRequested
|
||||
@@ -14501,6 +14733,7 @@ impl<'a> Game<'a> {
|
||||
pending_entry: None,
|
||||
world_loader: None,
|
||||
pending_pack_reload: false,
|
||||
pack_reload: None,
|
||||
server_packs: Default::default(),
|
||||
link_prompt: None,
|
||||
code_of_conduct: None,
|
||||
@@ -14596,6 +14829,7 @@ impl<'a> Game<'a> {
|
||||
last_input: now,
|
||||
pad: Default::default(),
|
||||
frames: 0,
|
||||
glint_frame: Default::default(),
|
||||
item_level_cache: Default::default(),
|
||||
world_frames: 0,
|
||||
fps: 0.0,
|
||||
@@ -14678,6 +14912,7 @@ impl<'a> Game<'a> {
|
||||
heights: Default::default(),
|
||||
sway: Default::default(),
|
||||
visible_hands: [(0, None), (0, None)],
|
||||
hands_replaced: [false; 2],
|
||||
clicks: Default::default(),
|
||||
attack_clicks: 0,
|
||||
hand_strength: Default::default(),
|
||||
@@ -15983,8 +16218,9 @@ impl Loading {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
// The Window constructor: created fullscreen when options.txt says so.
|
||||
let fullscreen = self.settings.fullscreen;
|
||||
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
|
||||
let fullscreen =
|
||||
self.settings.fullscreen || self.options.as_ref().is_some_and(|o| o.fullscreen);
|
||||
let size = self
|
||||
.options
|
||||
.as_ref()
|
||||
@@ -16353,6 +16589,8 @@ fn run_loading(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub use launch_api::{UsageError, exit_code};
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let Some(mut options) = Options::read()? else {
|
||||
return Ok(());
|
||||
@@ -16752,7 +16990,10 @@ mod tests {
|
||||
fn selecting_creative_tabs_does_not_drop_the_carried_stack_on_release() {
|
||||
let mut creative = Creative::from_json(&serde_json::json!({
|
||||
"schema": 2,
|
||||
"tabs": [{"type": "CATEGORY"}, {"type": "INVENTORY"}]
|
||||
"tabs": [
|
||||
{"type": "CATEGORY", "items": ["{count:1,id:\"minecraft:stone\"}"]},
|
||||
{"type": "INVENTORY"}
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
for button in [Button::Left, Button::Right] {
|
||||
|
||||
@@ -7,7 +7,6 @@ use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
block_entity_anim::Ticker,
|
||||
chunk::{MIN_Y, SECTIONS},
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Species,
|
||||
@@ -549,7 +548,7 @@ impl Game<'_> {
|
||||
selected,
|
||||
} => json!({
|
||||
"mode": "select-world",
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
|
||||
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1, "corrupted": w.corrupted })).collect::<Vec<_>>(),
|
||||
"selected": selected,
|
||||
}),
|
||||
Mode::Create {
|
||||
@@ -603,6 +602,17 @@ impl Game<'_> {
|
||||
Mode::BackupBeforeUpgrade { world, .. } => {
|
||||
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::Recover { world, issues } => {
|
||||
let issue = |i: &Option<horizon_client::save::DatIssue>| match i {
|
||||
None => json!(null),
|
||||
Some(horizon_client::save::DatIssue::Missing) => json!("missing file"),
|
||||
Some(horizon_client::save::DatIssue::Unreadable(why)) => json!(why),
|
||||
};
|
||||
json!({ "mode": "recover-world", "folder": world.folder, "level_dat": issue(&issues[0]), "level_dat_old": issue(&issues[1]) })
|
||||
}
|
||||
Mode::Recovered { world, success } => {
|
||||
json!({ "mode": "recovery-done", "folder": world.folder, "success": success })
|
||||
}
|
||||
Mode::Error { message, .. } => {
|
||||
json!({ "mode": "world-operation-error", "message": message })
|
||||
}
|
||||
@@ -1088,7 +1098,9 @@ impl Game<'_> {
|
||||
.world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.filter(|_| (MIN_Y..MIN_Y + SECTIONS as i32 * 16).contains(&y))
|
||||
.filter(|_| {
|
||||
(self.world.bottom_y()..self.world.top_y()).contains(&y)
|
||||
})
|
||||
.map(|c| c.light(x & 15, y, z & 15));
|
||||
let digit = |v: u8| char::from_digit(u32::from(v), 16).unwrap_or('.');
|
||||
sky.push(packed.map_or('.', |v| digit(v >> 4)));
|
||||
|
||||
@@ -635,17 +635,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for i in dead.into_iter().rev() {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
let (pos, drops) = burnt_armor_stand_equipment(a);
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.break_armor_stand_by_anything(i);
|
||||
}
|
||||
}
|
||||
/// `ArmorStand.brokenByAnything`, then `kill`, as burning out or a blast
|
||||
/// breaks a stand: its equipment pops above its block, the stand item does
|
||||
/// not drop.
|
||||
pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
let (pos, drops) = broken_armor_stand_equipment(a);
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
|
||||
pub(super) fn armor_stand_geometry(
|
||||
&self,
|
||||
@@ -1055,7 +1061,15 @@ pub(super) fn skull_kind(item: &str) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
|
||||
/// `ArmorStand.ignoreExplosion` (an invisible stand), and `hurtServer`
|
||||
/// refusing an invisible or marker stand: such a stand outlasts any blast.
|
||||
pub(super) fn spared_by_blast(a: &ArmorStand) -> bool {
|
||||
a.invisible || a.marker
|
||||
}
|
||||
|
||||
/// `ArmorStand.brokenByAnything`: the equipment, less what curse of vanishing
|
||||
/// keeps (`PREVENT_EQUIPMENT_DROP`), popped at `blockPosition().above()`.
|
||||
fn broken_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
|
||||
let pos = [
|
||||
a.position.x.floor() as i32,
|
||||
a.position.y.floor() as i32 + 1,
|
||||
@@ -1073,6 +1087,16 @@ fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
|
||||
mod burn_drop_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn only_visible_non_marker_stands_are_broken_by_blasts() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
assert!(!spared_by_blast(&stand));
|
||||
stand.invisible = true;
|
||||
assert!(spared_by_blast(&stand));
|
||||
stand.invisible = false;
|
||||
stand.marker = true;
|
||||
assert!(spared_by_blast(&stand));
|
||||
}
|
||||
#[test]
|
||||
fn burnt_equipment_drops_above_the_block_and_vanishing_items_do_not_drop() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(-0.1, 4.8, 0.0), 0.0);
|
||||
let kept = ItemStack::new("minecraft:diamond_boots", 1);
|
||||
@@ -1083,7 +1107,7 @@ mod burn_drop_tests {
|
||||
.push(("minecraft:vanishing_curse".into(), 1));
|
||||
stand.equipment[0] = Some(kept.clone());
|
||||
stand.equipment[1] = Some(vanished);
|
||||
let (pos, drops) = burnt_armor_stand_equipment(stand);
|
||||
let (pos, drops) = broken_armor_stand_equipment(stand);
|
||||
assert_eq!(pos, [-1, 5, 0]);
|
||||
assert_eq!(drops, vec![kept]);
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if b.last_check_y >= surface {
|
||||
b.last_check_y = super::MIN_Y - 1;
|
||||
b.last_check_y = self.world.bottom_y() - 1;
|
||||
b.sections = std::mem::take(&mut b.checking);
|
||||
let (was, is) = (previous > 0, b.levels > 0);
|
||||
if !was && is {
|
||||
@@ -231,7 +231,7 @@ impl Game<'_> {
|
||||
let mut levels = 0;
|
||||
for step in 1..=4 {
|
||||
let ly = y - step;
|
||||
if ly < super::MIN_Y {
|
||||
if ly < self.world.bottom_y() {
|
||||
break;
|
||||
}
|
||||
let ok = (x - step..=x + step).all(|lx| {
|
||||
|
||||
@@ -461,7 +461,7 @@ impl Game<'_> {
|
||||
if is_conduit(section.get(i)) {
|
||||
found.push([
|
||||
cx * 16 + (i & 15) as i32,
|
||||
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
|
||||
self.world.bottom_y() + si as i32 * 16 + (i >> 8) as i32,
|
||||
cz * 16 + ((i >> 4) & 15) as i32,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ use horizon_client::{
|
||||
items::ItemStack, save,
|
||||
};
|
||||
use horizon_world::{
|
||||
nbt::{Compound, Tag},
|
||||
level::classes::Class,
|
||||
nbt::{Compound, JavaString, Tag},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
|
||||
@@ -45,7 +46,17 @@ impl Game<'_> {
|
||||
if &*facts.item == "minecraft:air" {
|
||||
return None;
|
||||
}
|
||||
let mut stack = ItemStack::new(&facts.item, 1);
|
||||
// FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant
|
||||
// (`new ItemStack(potted)`), an empty pot as the pot itself.
|
||||
let behaviours = self.res.behaviours();
|
||||
let plant = behaviours.data(state);
|
||||
let plant_item = plant
|
||||
.is(Class::FlowerPot)
|
||||
.then_some(plant.link)
|
||||
.flatten()
|
||||
.map(|block| behaviours.block_data(block).item.clone())
|
||||
.filter(|item| &**item != "minecraft:air");
|
||||
let mut stack = ItemStack::new(plant_item.as_ref().unwrap_or(&facts.item), 1);
|
||||
let mut patch = Compound::new();
|
||||
// BeehiveBlock.getCloneItemStack: the honey level as the item's block state.
|
||||
if include_data
|
||||
@@ -62,16 +73,53 @@ impl Game<'_> {
|
||||
Tag::compound([("honey_level", Tag::String(level.as_str().into()))]),
|
||||
);
|
||||
}
|
||||
// CopperGolemStatueBlock.getCloneItemStack: the statue's pose as the item's
|
||||
// block_state (`CopperGolemStatueBlockEntity.getItem`), data or not.
|
||||
if let Some(pose) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| s.name.ends_with("copper_golem_statue"))
|
||||
.and_then(|s| s.properties.get("copper_golem_pose"))
|
||||
{
|
||||
patch.insert(
|
||||
"minecraft:block_state".into(),
|
||||
Tag::compound([("copper_golem_pose", Tag::String(pose.as_str().into()))]),
|
||||
);
|
||||
}
|
||||
// LightBlock.getCloneItemStack, setLightOnStack: the light's level as
|
||||
// the item's block state, whether or not the block data is picked.
|
||||
if let Some(level) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| s.name.as_str() == "minecraft:light")
|
||||
.and_then(|s| s.properties.get("level"))
|
||||
{
|
||||
patch.insert(
|
||||
"minecraft:block_state".into(),
|
||||
Tag::compound([("level", Tag::String(level.as_str().into()))]),
|
||||
);
|
||||
}
|
||||
// AbstractBannerBlock.getCloneItemStack: the banner's own item
|
||||
// (BannerBlockEntity.getItem), data or not.
|
||||
let banner = matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Banner(_))
|
||||
);
|
||||
if (include_data || banner)
|
||||
// DecoratedPotBlock.getCloneItemStack: createDecoratedPotInstance, the
|
||||
// sherds only; the pot's item comes with include_data.
|
||||
let pot = &*facts.name == "minecraft:decorated_pot";
|
||||
if (include_data || banner || pot)
|
||||
&& let Some(saved) = self.saved_block_entity(key)
|
||||
{
|
||||
patch.extend(block_entity_item::pick(&saved));
|
||||
let mut picked = block_entity_item::pick(&saved);
|
||||
if pot && !include_data {
|
||||
picked.retain(|k, _| *k == JavaString::from("minecraft:pot_decorations"));
|
||||
}
|
||||
patch.extend(picked);
|
||||
}
|
||||
if !patch.is_empty() {
|
||||
let mut all = components(&stack);
|
||||
|
||||
@@ -474,6 +474,7 @@ impl Game<'_> {
|
||||
if let Some(names) = self.remote_online_names() {
|
||||
self.ui.registries.players = names;
|
||||
}
|
||||
self.ui.registries.custom_completions = self.remote_custom_completions();
|
||||
}
|
||||
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
|
||||
/// last time, when this method can restore it (a command draft only for
|
||||
@@ -1204,7 +1205,7 @@ impl Game<'_> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let below_world = p.position.y < f64::from(horizon_client::chunk::MIN_Y - 64);
|
||||
let below_world = p.position.y < f64::from(self.world.bottom_y() - 64);
|
||||
let name = self.player_display_text();
|
||||
let message = match cause {
|
||||
Some(key) => Text::translate(&key, &[name]),
|
||||
|
||||
@@ -144,7 +144,7 @@ impl Game<'_> {
|
||||
.filter_map(|e| effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
|
||||
let invisible = effects.iter().any(|e| e.id == "invisibility");
|
||||
let invisible = self.own_invisible();
|
||||
let size = (
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
|
||||
@@ -30,7 +30,7 @@ type Pos = (i32, i32, i32);
|
||||
|
||||
/// The open `CommandBlockEditScreen`.
|
||||
pub(super) struct CommandBlockScreen {
|
||||
pub(super) pos: Pos,
|
||||
pos: Pos,
|
||||
/// The command box with its `CommandSuggestions`.
|
||||
command: ChatScreen,
|
||||
/// When the command box was focused, for its cursor's blink.
|
||||
@@ -112,6 +112,7 @@ impl CommandBlockScreen {
|
||||
self.previous.set_value(&shown);
|
||||
}
|
||||
}
|
||||
|
||||
fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> {
|
||||
registry.state(state)
|
||||
}
|
||||
@@ -240,21 +241,21 @@ impl Game<'_> {
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
|
||||
}
|
||||
/// `CommandBlockEditScreen.updateGui`: the server's block-entity data
|
||||
/// arrived while the screen is open; refresh the fields from the entity.
|
||||
/// The mode and conditional live in the block state here (vanilla's
|
||||
/// `CommandBlockEntity.Mode`).
|
||||
pub(super) fn update_command_block_screen(&mut self) {
|
||||
let Some(screen_pos) = self.command_block_screen.as_ref().map(|s| s.pos) else {
|
||||
/// `ClientPacketListener.handleBlockEntityData` then
|
||||
/// `CommandBlockEditScreen.updateGui`: new data for the command block the
|
||||
/// edit screen shows refreshes its fields. The mode and conditional live in
|
||||
/// the block state here (vanilla's `CommandBlockEntity.Mode`).
|
||||
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
|
||||
if self.command_block_screen.as_ref().map(|s| s.pos) != Some(pos) {
|
||||
return;
|
||||
};
|
||||
let state = self.block_state_at(screen_pos).map(|s| {
|
||||
}
|
||||
let state = self.block_state_at(pos).map(|s| {
|
||||
(
|
||||
Mode::of_block(&s.name).unwrap_or(Mode::Redstone),
|
||||
conditional(s),
|
||||
)
|
||||
});
|
||||
let block = self.command_block(screen_pos).cloned();
|
||||
let block = self.command_block(pos).cloned();
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
@@ -825,18 +826,6 @@ impl Game<'_> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
/// `ClientPacketListener.handleBlockEntityData`: new data for the command
|
||||
/// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`).
|
||||
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
|
||||
let open = self.screen == Screen::CommandBlock
|
||||
&& self
|
||||
.command_block_screen
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.pos == pos);
|
||||
if open {
|
||||
self.open_command_block(pos);
|
||||
}
|
||||
}
|
||||
/// What `handleSetCommandBlock` does once the player may edit the block.
|
||||
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
|
||||
let pos = s.pos;
|
||||
|
||||
@@ -301,9 +301,7 @@ impl Game<'_> {
|
||||
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
}
|
||||
if p[1] < horizon_client::chunk::MIN_Y
|
||||
|| p[1] >= horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT
|
||||
{
|
||||
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
|
||||
return Err(err("argument.pos.outofworld", &[]));
|
||||
}
|
||||
Ok(p)
|
||||
@@ -689,7 +687,14 @@ impl Game<'_> {
|
||||
.iter()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|p| {
|
||||
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
p.is_arrow()
|
||||
|| matches!(
|
||||
p.kind,
|
||||
K::LargeFireball { .. }
|
||||
| K::SmallFireball
|
||||
| K::WindCharge
|
||||
| K::BreezeWindCharge
|
||||
)
|
||||
})
|
||||
.map(|p| Target::Projectile(p.id)),
|
||||
);
|
||||
@@ -719,6 +724,27 @@ impl Game<'_> {
|
||||
});
|
||||
dims
|
||||
}
|
||||
/// `Level.getEntity`: the entity of the level in place with a network
|
||||
/// id, players aside.
|
||||
pub(in crate::game) fn level_entity(&self, id: i32) -> Option<Target> {
|
||||
let id = u32::try_from(id).ok()?;
|
||||
[
|
||||
Target::Mob(id),
|
||||
Target::Item(id),
|
||||
Target::Orb(id),
|
||||
Target::Projectile(id),
|
||||
Target::Stand(id),
|
||||
Target::Frame(id),
|
||||
Target::Painting(id),
|
||||
Target::Boat(id),
|
||||
Target::Cart(id),
|
||||
Target::Other(id),
|
||||
Target::Crystal(id),
|
||||
Target::Dragon(id),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|t| self.level_holds(*t))
|
||||
}
|
||||
/// Whether the level in place holds the entity a target names.
|
||||
fn level_holds(&self, t: Target) -> bool {
|
||||
match t {
|
||||
@@ -1034,6 +1060,11 @@ impl Game<'_> {
|
||||
let custom = s.data().and_then(|data| data.custom_name.as_ref());
|
||||
let hover = if let Some(c) = custom {
|
||||
horizon_client::component::name_text(c)
|
||||
} else if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
|
||||
// `ItemStack.getItemName`: the `item_name` component wins over the
|
||||
// item's own name (`Item.getName`), so a datapack's or a server's
|
||||
// renaming shows here as it does on the tooltip's first line.
|
||||
horizon_client::component::name_text(name)
|
||||
} else if let Some(title) = s
|
||||
.data()
|
||||
.and_then(|d| d.written.as_ref())
|
||||
@@ -2966,7 +2997,7 @@ impl Game<'_> {
|
||||
if !section.blocks.maybe_has(&mut wanted_state) {
|
||||
continue;
|
||||
}
|
||||
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
|
||||
let base_y = self.world.bottom_y() + 16 * i as i32;
|
||||
for j in 0..4096 {
|
||||
let state = section.get(j);
|
||||
let Some(kind) = cache.get(&state).copied().flatten() else {
|
||||
@@ -3028,8 +3059,8 @@ impl Game<'_> {
|
||||
radius: 6400,
|
||||
horizontal: 32,
|
||||
vertical: 64,
|
||||
min_y: horizon_client::chunk::MIN_Y,
|
||||
max_y: horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1,
|
||||
min_y: self.world.bottom_y(),
|
||||
max_y: self.world.top_y() - 1,
|
||||
};
|
||||
let found = self
|
||||
.end
|
||||
|
||||
@@ -318,6 +318,9 @@ impl Game<'_> {
|
||||
self.take_loot_table(key, &tag, &mut new);
|
||||
self.world.block_entities.insert(key, new);
|
||||
self.update_spawner_display(key);
|
||||
// BlockDataAccessor.setData: setChanged and sendBlockUpdated(3),
|
||||
// even when the block state itself stays the same.
|
||||
self.mark_block_entity_updated(key);
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
// Redraw what shows the block entity (signs, banners).
|
||||
let _ = self.world.set(p[0], p[1], p[2], state);
|
||||
|
||||
@@ -785,8 +785,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// The highest block of a heightmap type at a column (`Level.getHeight`).
|
||||
fn heightmap_at(&self, kind: &str, x: i32, z: i32) -> i32 {
|
||||
let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT;
|
||||
for y in (horizon_client::chunk::MIN_Y..top).rev() {
|
||||
let top = self.world.top_y();
|
||||
for y in (self.world.bottom_y()..top).rev() {
|
||||
let state = self.world.block(x, y, z);
|
||||
if state == self.world.air {
|
||||
continue;
|
||||
@@ -809,7 +809,7 @@ impl Game<'_> {
|
||||
return y + 1;
|
||||
}
|
||||
}
|
||||
horizon_client::chunk::MIN_Y
|
||||
self.world.bottom_y()
|
||||
}
|
||||
/// A `store` modifier: a new callback in the source's chain.
|
||||
fn store_source(&mut self, c: &Context, s: Source) -> R<Source> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! `/forceload`, `/fillbiome`, `/worldborder` and `/loot`.
|
||||
use super::{Arg, Context, Game, R, Target, err, items::Owner};
|
||||
use horizon_client::{
|
||||
chunk::{HEIGHT, MIN_Y},
|
||||
command::CommandError,
|
||||
items::ItemStack,
|
||||
scoreboard_cmd::{Holder, Tracker},
|
||||
@@ -213,7 +212,8 @@ impl Game<'_> {
|
||||
if usize::from(current) == target {
|
||||
continue;
|
||||
}
|
||||
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
|
||||
let qy = ((y - self.world.bottom_y()) >> 2)
|
||||
.clamp(0, self.world.layout().height() / 4 - 1);
|
||||
if let Some(chunk) = self.world.chunks.get_mut(&key) {
|
||||
let chunk = std::sync::Arc::make_mut(chunk);
|
||||
chunk.set_biome_cell((x & 15) >> 2, qy, (z & 15) >> 2, target as u16);
|
||||
@@ -228,7 +228,7 @@ impl Game<'_> {
|
||||
}
|
||||
for key in &touched {
|
||||
self.world.modified.insert(*key);
|
||||
for sy in 0..horizon_client::chunk::SECTIONS as i32 {
|
||||
for sy in 0..self.world.layout().sections() as i32 {
|
||||
self.world.dirty_sections.insert((key.0, sy, key.1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,10 +202,15 @@ impl Game<'_> {
|
||||
attacker,
|
||||
from,
|
||||
};
|
||||
self.mobs
|
||||
let hurt = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.damage(amount, damage))
|
||||
.is_some_and(|m| m.damage(amount, damage));
|
||||
if hurt {
|
||||
self.resolve_player_responsible(id, damage);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
@@ -1278,12 +1283,12 @@ impl Game<'_> {
|
||||
let respect = matches!(ctx.get("respectTeams"), Some(Arg::Bool(true)));
|
||||
let max_y = match ctx.get("maxHeight") {
|
||||
Some(Arg::Int(h)) => *h,
|
||||
_ => horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT,
|
||||
_ => self.world.top_y(),
|
||||
};
|
||||
if max_y < horizon_client::chunk::MIN_Y {
|
||||
if max_y < self.world.bottom_y() {
|
||||
return Err(err(
|
||||
"commands.spreadplayers.failed.invalid.height",
|
||||
&[&max_y, &horizon_client::chunk::MIN_Y],
|
||||
&[&max_y, &self.world.bottom_y()],
|
||||
));
|
||||
}
|
||||
let targets = self.entities(ctx, "targets", false)?;
|
||||
@@ -1484,7 +1489,7 @@ impl Game<'_> {
|
||||
let mut air2 = air(y);
|
||||
y -= 1;
|
||||
let mut air1 = air(y);
|
||||
while y > horizon_client::chunk::MIN_Y {
|
||||
while y > self.world.bottom_y() {
|
||||
y -= 1;
|
||||
let current = air(y);
|
||||
if !current && air1 && air2 {
|
||||
|
||||
@@ -342,7 +342,7 @@ impl Game<'_> {
|
||||
let (lx, lz, ly) = ((i & 15) as i32, ((i >> 4) & 15) as i32, (i >> 8) as i32);
|
||||
let pos = (
|
||||
key.0 * 16 + lx,
|
||||
horizon_client::chunk::MIN_Y + si as i32 * 16 + ly,
|
||||
self.world.bottom_y() + si as i32 * 16 + ly,
|
||||
key.1 * 16 + lz,
|
||||
);
|
||||
self.world
|
||||
|
||||
@@ -107,12 +107,10 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
hit.direction.normal(),
|
||||
))
|
||||
}
|
||||
fn in_water(&self, at: Vec3) -> bool {
|
||||
self.w.fluid(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
) == Fluid::Water
|
||||
/// Entity.updateFluidInteraction over the projectile's box, not the
|
||||
/// block at its feet (#3072).
|
||||
fn in_water(&self, bounds: Aabb) -> bool {
|
||||
self.w.fluids(bounds, bounds.max_position().y).in_water
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)] {
|
||||
&self.targets
|
||||
@@ -130,21 +128,35 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
|
||||
/// `AbstractWindCharge` is pickable, so a thrown pearl's
|
||||
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
|
||||
const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1;
|
||||
/// `WIND_CHARGE_IDS + j` is `projectiles[j]` during `tick_projectiles`, below
|
||||
/// the dragon's reserved ids.
|
||||
const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000;
|
||||
|
||||
fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> {
|
||||
/// The index of the wind charge a `wind_charge_hitboxes` id stands for.
|
||||
fn wind_charge_hit(id: u32) -> Option<usize> {
|
||||
(WIND_CHARGE_IDS..super::dragon::SPECIAL)
|
||||
.contains(&id)
|
||||
.then(|| (id - WIND_CHARGE_IDS) as usize)
|
||||
}
|
||||
|
||||
fn wind_charge_hitboxes(
|
||||
projectiles: &[Projectile],
|
||||
alive: &[bool],
|
||||
skip: usize,
|
||||
) -> Vec<(Who, Aabb)> {
|
||||
projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, q)| {
|
||||
*j != skip
|
||||
&& alive[*j]
|
||||
&& !q.stuck
|
||||
&& matches!(
|
||||
q.kind,
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
|
||||
)
|
||||
})
|
||||
.map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb()))
|
||||
.map(|(j, q)| (Who::Mob(WIND_CHARGE_IDS + j as u32), q.aabb()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -354,6 +366,41 @@ impl Game<'_> {
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// `Mob.convertTo(type, ConversionParams.single(...))` for a mob already
|
||||
/// removed: a new mob of `kind`, made without spawn finalization, takes
|
||||
/// its place with what `conversion::single` carries over, and the old
|
||||
/// mob's riders move onto it. A zombie that comes out of it rolls
|
||||
/// `Zombie.handleAttributes(special, CONVERSION)` (the
|
||||
/// `postConversionStep` of its `ConversionTracker`).
|
||||
pub(super) fn convert_to(
|
||||
&mut self,
|
||||
from: &Mob,
|
||||
kind: &str,
|
||||
params: horizon_client::mobs::conversion::Params,
|
||||
) -> Option<u32> {
|
||||
let t = self.res.mob_types().get(kind).cloned()?;
|
||||
self.next_entity += 1;
|
||||
let mut mob = Mob::new(self.next_entity, t, from.position, from.yaw);
|
||||
horizon_client::mobs::conversion::single(from, &mut mob, params);
|
||||
if matches!(from.species(), Species::Zombie | Species::Husk) {
|
||||
let at = [from.position.x, from.position.y, from.position.z];
|
||||
let special = self.local_difficulty(at).special();
|
||||
horizon_client::mobs::finalize::zombie_attributes(
|
||||
&mut mob,
|
||||
special,
|
||||
horizon_client::mobs::finalize::Reason::Conversion,
|
||||
);
|
||||
}
|
||||
let id = mob.id;
|
||||
// ConversionType.SINGLE: the passengers get off and board the new mob.
|
||||
for rider in self.mobs.iter_mut() {
|
||||
if rider.extra.mount == Some(from.id) {
|
||||
rider.extra.mount = Some(id);
|
||||
}
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
Some(id)
|
||||
}
|
||||
/// The `summon` command: spawn a mob in front of the player or at a position.
|
||||
pub(super) fn summon(&mut self, kind: &str, at: Option<[f64; 3]>, baby: bool) -> Result<u32> {
|
||||
let at = match at {
|
||||
@@ -454,13 +501,13 @@ impl Game<'_> {
|
||||
/// Highest motion-blocking (no leaves) block + 1 (Heightmap MOTION_BLOCKING_NO_LEAVES).
|
||||
pub(super) fn motion_top(&self, x: i32, z: i32) -> i32 {
|
||||
let top = if self.dim == Dim::Nether { 127 } else { 319 };
|
||||
for y in (horizon_client::chunk::MIN_Y..=top).rev() {
|
||||
for y in (self.world.bottom_y()..=top).rev() {
|
||||
let f = self.res.facts().get(self.world.block(x, y, z));
|
||||
if (f.full_collision || f.fluid != Fluid::None) && !f.name.ends_with("leaves") {
|
||||
return y + 1;
|
||||
}
|
||||
}
|
||||
horizon_client::chunk::MIN_Y
|
||||
self.world.bottom_y()
|
||||
}
|
||||
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
@@ -883,8 +930,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.score_entity_removed(m.id);
|
||||
self.raider_died(m, by_player);
|
||||
self.villager_murdered(m, by_player, killer.flatten());
|
||||
// Raider.die's hero and Villager.tellWitnessesThatIWasMurdered go to
|
||||
// the causing entity, not the credited player: a tame wolf's kill is
|
||||
// neither its owner's heroism nor a murder by them.
|
||||
let slain_by_player = by_player && !killed_by_mob(m);
|
||||
self.raider_died(m, slain_by_player);
|
||||
self.villager_murdered(m, slain_by_player, killer.flatten());
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
self.drop_death_loot(&mut dying, by_player, consumed, pos);
|
||||
self.create_wither_rose(m);
|
||||
@@ -919,7 +970,12 @@ impl Game<'_> {
|
||||
/// The killer's side of a mob's death: `ServerPlayer.awardKillScore`'s
|
||||
/// statistics and scores, and the `player_killed_entity` trigger.
|
||||
fn credit_kill(&mut self, m: &Mob) {
|
||||
self.stats.add("killed", &m.kind.id, 1);
|
||||
// Player.killedEntity: the killed statistic goes to the causing
|
||||
// entity, so a kill by a mob (a tame wolf credited to its owner)
|
||||
// leaves it out; ServerPlayer.awardKillScore counts the rest.
|
||||
if !killed_by_mob(m) {
|
||||
self.stats.add("killed", &m.kind.id, 1);
|
||||
}
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(m.id));
|
||||
let view = self.mob_entity(m);
|
||||
@@ -1056,6 +1112,20 @@ impl Game<'_> {
|
||||
// the orbs' merge ids and block ticks read next).
|
||||
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
|
||||
// of the carried block's silk-touch drops.
|
||||
let stacks = horizon_client::mobs::enderman::carried_block_drops(
|
||||
world_items!(self),
|
||||
self.pack.assets.registry,
|
||||
state,
|
||||
&mut self.world.random,
|
||||
Some((&mut self.rand_sequences, self.level.seed)),
|
||||
);
|
||||
for item in stacks {
|
||||
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
}
|
||||
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
|
||||
drops::Drop::Orb(_) => {}
|
||||
}
|
||||
@@ -1088,6 +1158,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if hurt {
|
||||
self.resolve_player_responsible(target, source);
|
||||
}
|
||||
if infested {
|
||||
self.mob_infested(target);
|
||||
}
|
||||
@@ -1110,6 +1183,49 @@ impl Game<'_> {
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `LivingEntity.resolvePlayerResponsibleForDamage` after `source` hurt a
|
||||
/// mob: a tame wolf's hit is its owner's (`setLastHurtByPlayer(uuid, 100)`),
|
||||
/// so the kill drops experience and player-only loot and the owner, when
|
||||
/// in the world, gets the credit (`getKillCredit`); a tame wolf without an
|
||||
/// owner clears it. A player's own hits are recorded where they land.
|
||||
pub(super) fn resolve_player_responsible(&mut self, victim: u32, source: Damage) {
|
||||
let Some(Who::Mob(attacker)) = source.attacker else {
|
||||
return;
|
||||
};
|
||||
let Some(owner) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == attacker && m.species() == Species::Wolf && m.extra.tame)
|
||||
.map(|m| m.extra.owner)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let player = owner.and_then(|uuid| self.player_with_uuid(uuid));
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == victim) else {
|
||||
return;
|
||||
};
|
||||
m.ai.last_hurt_by_player = if owner.is_some() { 100 } else { 0 };
|
||||
match player {
|
||||
Some(who) => self.last_hurt_by_player.insert(victim, who),
|
||||
None => self.last_hurt_by_player.remove(&victim),
|
||||
};
|
||||
}
|
||||
/// The player in the world with `uuid` (`EntityReference.getPlayer`), as
|
||||
/// `last_hurt_by_player` keeps them: None for the player in place, else the
|
||||
/// guest's connection.
|
||||
fn player_with_uuid(&self, uuid: [i32; 4]) -> Option<Option<u32>> {
|
||||
if self.player.is_some()
|
||||
&& horizon_client::mobs::tame::owned_by(Some(uuid), self.acting_uuid())
|
||||
{
|
||||
return Some(self.lan_acting);
|
||||
}
|
||||
let uuid = horizon_client::lan::playerdata::ints_uuid(uuid);
|
||||
self.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter())
|
||||
.find(|g| g.placed && g.uuid == uuid)
|
||||
.map(|g| Some(g.conn))
|
||||
}
|
||||
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
|
||||
pub(super) fn mob_infested(&mut self, mob: u32) {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
|
||||
@@ -1361,6 +1477,31 @@ impl Game<'_> {
|
||||
} => {
|
||||
self.mob_hits_mob(target, amount, source);
|
||||
}
|
||||
MobEvent::CubeHurtMob {
|
||||
cube,
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
} => {
|
||||
// AbstractCubeMob.dealDamage: the attack sound (and its
|
||||
// two pitch draws, made even for a silent cube, whose
|
||||
// Entity.playSound is then skipped) only follows a hit
|
||||
// that landed.
|
||||
if self.mob_hits_mob(target, amount, source)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == cube)
|
||||
{
|
||||
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0;
|
||||
let at = m.center();
|
||||
if !m.silent {
|
||||
self.handle_mob_events(vec![MobEvent::Sound(
|
||||
"entity.slime.attack",
|
||||
at,
|
||||
1.0,
|
||||
pitch,
|
||||
)])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::SonicBoom {
|
||||
target,
|
||||
attacker,
|
||||
@@ -1503,6 +1644,9 @@ impl Game<'_> {
|
||||
self.apply_setup(id, &setup);
|
||||
}
|
||||
}
|
||||
MobEvent::Convert { from, kind, params } => {
|
||||
self.convert_to(&from, kind, params);
|
||||
}
|
||||
MobEvent::DropItem { item, count, at } => self.spawn_item(
|
||||
ItemStack {
|
||||
item,
|
||||
@@ -2113,6 +2257,8 @@ impl Game<'_> {
|
||||
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
child.extra.age = -24000;
|
||||
// No Mob.finalizeSpawn either: no follow range bonus, right-handed.
|
||||
spawning::bred_offspring(child);
|
||||
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
|
||||
// the default (`classic`) sound variant.
|
||||
child.extra.sound_variant = None;
|
||||
@@ -2645,7 +2791,7 @@ impl Game<'_> {
|
||||
let mut keep_flags = vec![true; projectiles.len()];
|
||||
for i in 0..projectiles.len() {
|
||||
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) {
|
||||
wind_charge_hitboxes(&projectiles, i)
|
||||
wind_charge_hitboxes(&projectiles, &keep_flags, i)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -2754,6 +2900,16 @@ impl Game<'_> {
|
||||
hits.push((p.clone(), hit));
|
||||
}
|
||||
keep_flags[i] = keep;
|
||||
// Projectile.onHit: the wind charge it hit turns before its own tick.
|
||||
if let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} = hit
|
||||
&& let Some(j) = wind_charge_hit(id)
|
||||
{
|
||||
let (owner, guest) = (p.owner, p.guest);
|
||||
let look = self.owner_look(owner, guest);
|
||||
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
|
||||
}
|
||||
}
|
||||
let mut idx = 0;
|
||||
projectiles.retain(|_| {
|
||||
@@ -2863,11 +3019,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `onProjectileHit` of chorus flowers and decorated pots: an impact
|
||||
/// projectile (`#impact_projectiles`) breaks the block when
|
||||
/// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes
|
||||
/// the `projectiles_can_break_blocks` rule and a shooter that may break
|
||||
/// blocks (`canBreakBlockInAdventureMode`: never a mob, never an
|
||||
/// `onProjectileHit` of chorus flowers, decorated pots and speleothems
|
||||
/// (pointed dripstone, sulfur spikes): an impact projectile
|
||||
/// (`#impact_projectiles`) breaks the block when `Projectile.mayInteract`
|
||||
/// and `Projectile.mayBreak` allow it, which takes the
|
||||
/// `projectiles_can_break_blocks` rule and a shooter that may break blocks
|
||||
/// (`canBreakBlockInAdventureMode`: never a mob, never an
|
||||
/// adventure player). A pot is cracked first (flags 260), so it shatters.
|
||||
fn projectile_breaks_block(&mut self, p: &Projectile, pos: [i32; 3]) {
|
||||
use horizon_client::command::args::GameMode;
|
||||
@@ -2895,6 +3052,11 @@ impl Game<'_> {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
match &*self.res.facts().get(state).name {
|
||||
"minecraft:chorus_flower" => {}
|
||||
// SpeleothemBlock.onProjectileHit (pointed dripstone and sulfur spikes): a
|
||||
// thrown trident faster than 0.6 breaks it.
|
||||
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
|
||||
if matches!(p.kind, ProjectileKind::Trident)
|
||||
&& p.arrow.as_ref().is_some_and(|a| a.hit_speed > 0.6) => {}
|
||||
"minecraft:decorated_pot" => {
|
||||
let registry = &self.pack.assets.registry;
|
||||
let Some(mut pot) = registry.state(state).cloned() else {
|
||||
@@ -4801,6 +4963,11 @@ impl Game<'_> {
|
||||
if mob.invisible && outline.is_none() {
|
||||
continue;
|
||||
}
|
||||
// LevelRenderer: the entity the view looks out of is not drawn
|
||||
// in first person.
|
||||
if self.hides_as_camera(mob.id) {
|
||||
continue;
|
||||
}
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
@@ -5123,12 +5290,12 @@ impl Game<'_> {
|
||||
let Some(r) = atlas.sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let s = 0.25 * a.sprite_scale();
|
||||
for (k, uv) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||
let lift = is_item_billboard(&a.kind);
|
||||
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
|
||||
for (v, uv) in quad
|
||||
.iter()
|
||||
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
|
||||
{
|
||||
let v = c + right * k.0 * s + up * k.1 * s;
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
@@ -5158,6 +5325,36 @@ fn thrown_item_axes(
|
||||
let orientation = super::camera_rotation(camera_type, yaw, pitch);
|
||||
(orientation * glam::Vec3::X, orientation * glam::Vec3::Y)
|
||||
}
|
||||
/// The four corners of the item billboard, in the order its UVs are laid out.
|
||||
/// `ThrownItemRenderer` only scales and billboards it; the lift is
|
||||
/// `item/generated`'s ground display transform, `translation [0, 2, 0]` of 16
|
||||
/// units = 0.125 blocks, which is `0.125 * scale` == `0.5 * s`.
|
||||
fn thrown_item_quad(
|
||||
at: glam::Vec3,
|
||||
right: glam::Vec3,
|
||||
up: glam::Vec3,
|
||||
scale: f32,
|
||||
lift: bool,
|
||||
) -> [glam::Vec3; 4] {
|
||||
let s = 0.25 * scale;
|
||||
let rise = if lift { 0.5 * s } else { 0.0 };
|
||||
let centre = at + up * rise;
|
||||
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||
.map(|k| centre + right * k.0 * s + up * k.1 * s)
|
||||
}
|
||||
/// Whether vanilla draws the kind as `ThrownItemRenderer`'s item billboard, the
|
||||
/// only ones that take that lift. Llama spit shares this quad but
|
||||
/// `LlamaSpitRenderer` is a model, with its own offset.
|
||||
fn is_item_billboard(kind: &ProjectileKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
ProjectileKind::Snowball
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::EnderPearl
|
||||
| ProjectileKind::SmallFireball
|
||||
| ProjectileKind::LargeFireball { .. }
|
||||
)
|
||||
}
|
||||
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
|
||||
/// bucket picks up.
|
||||
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
|
||||
@@ -5319,6 +5516,55 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::super::perspective::CameraType;
|
||||
use super::thrown_item_axes;
|
||||
use super::thrown_item_quad;
|
||||
use super::{ProjectileKind, is_item_billboard};
|
||||
|
||||
/// The component-wise lowest and highest corner of the quad.
|
||||
fn bounds(corners: &[glam::Vec3; 4]) -> (glam::Vec3, glam::Vec3) {
|
||||
(
|
||||
corners.iter().copied().reduce(glam::Vec3::min).unwrap(),
|
||||
corners.iter().copied().reduce(glam::Vec3::max).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_billboard_lifts_by_the_generated_ground_transform() {
|
||||
// The kinds vanilla draws with `ThrownItemRenderer`: the only lifted ones.
|
||||
assert!(is_item_billboard(&ProjectileKind::Snowball));
|
||||
assert!(is_item_billboard(&ProjectileKind::Egg));
|
||||
assert!(is_item_billboard(&ProjectileKind::EnderPearl));
|
||||
assert!(is_item_billboard(&ProjectileKind::SmallFireball));
|
||||
let large = ProjectileKind::LargeFireball { power: 1 };
|
||||
assert!(is_item_billboard(&large));
|
||||
// A projectile at y = 100 with right = +X, up = +Y: the sprite sits
|
||||
// 0.125 blocks above the entity (2 of `item/generated`'s 16 units) and
|
||||
// stays 0.5 blocks wide, so the quad's y runs 99.875 .. 100.375.
|
||||
let at = glam::Vec3::Y * 100.0;
|
||||
let (right, up) = (glam::Vec3::X, glam::Vec3::Y);
|
||||
let lifted = is_item_billboard(&ProjectileKind::Snowball);
|
||||
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
|
||||
assert_eq!((lo.y, hi.y), (99.875, 100.375));
|
||||
assert_eq!((lo.x, hi.x), (-0.25, 0.25));
|
||||
// A small fireball's 0.75 scale: the same quad scaled down, lifted by
|
||||
// 0.09375, half extent 0.1875.
|
||||
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 0.75, lifted));
|
||||
assert_eq!((lo.y, hi.y), (99.90625, 100.28125));
|
||||
// A large fireball's 3.0: lift 0.375, half extent 0.75.
|
||||
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 3.0, lifted));
|
||||
assert_eq!((lo.y, hi.y), (99.625, 101.125));
|
||||
// Llama spit shares the quad here but is a model in vanilla
|
||||
// (`LlamaSpitRenderer`): no lift at all.
|
||||
let lifted = is_item_billboard(&ProjectileKind::LlamaSpit);
|
||||
assert!(!lifted);
|
||||
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
|
||||
assert_eq!((lo.y, hi.y), (99.75, 100.25));
|
||||
// The rise follows the billboard's own up axis, not world up.
|
||||
let (right, up) = (glam::Vec3::Z, glam::Vec3::X);
|
||||
let lifted = is_item_billboard(&ProjectileKind::Egg);
|
||||
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
|
||||
assert_eq!((lo.x, hi.x), (-0.125, 0.375));
|
||||
assert_eq!((lo.y, hi.y), (100.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_billboard_does_not_flip_at_straight_up_or_down() {
|
||||
@@ -5349,6 +5595,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the killing blow's causing entity (`DamageSource.getEntity`) was a
|
||||
/// mob, such as a tame wolf whose owner gets the kill credit.
|
||||
fn killed_by_mob(m: &Mob) -> bool {
|
||||
m.last_damage
|
||||
.is_some_and(|(d, _)| matches!(d.attacker, Some(Who::Mob(_))))
|
||||
}
|
||||
|
||||
/// `Animal.finalizeSpawnChildFromBreeding`'s player: the one who fed the
|
||||
/// parent, or else the one who fed the partner (`getLoveCause`). `Some(None)`
|
||||
/// is the host, `Some(Some(conn))` a guest. Both animals are spent.
|
||||
@@ -5379,3 +5632,128 @@ mod breeding_tests {
|
||||
assert_eq!(breeding_credit(&mut love, 1, 2), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pearl_tests {
|
||||
use super::*;
|
||||
|
||||
/// A pearl's water test is `Entity.isInWater`: `wasTouchingWater` from the
|
||||
/// last `updateFluidInteraction`, water whose surface reaches its 0.25
|
||||
/// box (#3072), not the block at its feet.
|
||||
#[test]
|
||||
fn a_pearl_feels_the_water_its_box_touches() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-types.json").exists()
|
||||
|| !root.join("movement-properties.nbt").exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, registry).unwrap();
|
||||
let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let data = horizon_client::mobs::MobData::load(&root, &facts);
|
||||
let biomes = horizon_client::biome::Biomes::load(&root).unwrap();
|
||||
let items = horizon_client::items::Items::load(
|
||||
&root,
|
||||
registry,
|
||||
&facts,
|
||||
&horizon_world::loot::PackOverlay::NONE,
|
||||
)
|
||||
.unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, registry).unwrap();
|
||||
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
// A pool of still water one block deep at y = 4, from x = 20 on (clear of the
|
||||
// scene's pyramid): its
|
||||
// surface is at 4 + 8/9.
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
for x in 20..=40 {
|
||||
for z in 0..=2 {
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
let lid_openers = std::collections::HashMap::new();
|
||||
let w = MobWorld {
|
||||
teams: Default::default(),
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
fluid_tables: &motion.fluids,
|
||||
movement: &motion.blocks,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
player: None,
|
||||
nearest_player: None,
|
||||
others: &[],
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
daylight: true,
|
||||
monsters_burn: false,
|
||||
game_time: 1000,
|
||||
day_time: 1000,
|
||||
raining: false,
|
||||
thundering: false,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
sea_level: 63,
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
};
|
||||
let pw = ProjWorld {
|
||||
w: &w,
|
||||
targets: Vec::new(),
|
||||
};
|
||||
let pearl = |x: f64, y: f64, velocity: Vec3| {
|
||||
let mut p = Projectile::new(ProjectileKind::EnderPearl, Vec3::new(x, y, 1.5), None);
|
||||
p.velocity = velocity;
|
||||
p
|
||||
};
|
||||
// Skimming the pool: its feet are in the water block, its box above
|
||||
// the surface. ThrowableProjectile.applyInertia keeps the air drag.
|
||||
let mut p = pearl(25.5, 4.95, Vec3::new(0.3, 0.0, 0.0));
|
||||
p.tick(&pw);
|
||||
assert!(p.position.y > 4.9, "the pearl dipped: {p:?}");
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.x - 0.3 * 0.99 * 0.99).abs() < 1e-9,
|
||||
"a pearl above the surface slowed as in water: {p:?}"
|
||||
);
|
||||
// At the pool's edge: its feet over the dry block at x = 19, a side
|
||||
// of its box in the water at x = 20. The water drag after the first
|
||||
// tick, which still reads `wasTouchingWater` as false.
|
||||
let mut p = pearl(19.95, 4.3, Vec3::ZERO);
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
|
||||
"the first tick has the air drag: {p:?}"
|
||||
);
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
|
||||
"a pearl whose box is in the water kept the air drag: {p:?}"
|
||||
);
|
||||
// Thrown under water, a pearl moves with the air drag on its first
|
||||
// tick and the water drag after it.
|
||||
let mut p = pearl(30.5, 4.2, Vec3::ZERO);
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
|
||||
"the first tick under water has the air drag: {p:?}"
|
||||
);
|
||||
p.tick(&pw);
|
||||
assert!(
|
||||
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
|
||||
"the second tick under water has the water drag: {p:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ use horizon_client::{
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::{Axis, Vec3};
|
||||
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
|
||||
use horizon_world::nbt::Tag;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `ChatFormatting.YELLOW` and `RED`.
|
||||
@@ -217,7 +219,7 @@ impl Game<'_> {
|
||||
self.debug_charts.log_packets([sent, received]);
|
||||
}
|
||||
if shown {
|
||||
use horizon_protocol::packets::{play::Serverbound, status::Ping};
|
||||
use horizon_protocol::packets::status::Ping;
|
||||
let time = self.debug_millis();
|
||||
self.send(Serverbound::PingRequest(Ping { time }));
|
||||
}
|
||||
@@ -682,7 +684,7 @@ impl Game<'_> {
|
||||
/// `GameModeCommand.setGameMode` with its feedback.
|
||||
pub(super) fn change_game_mode(&mut self, mode: GameMode) {
|
||||
if self.remote.is_some() {
|
||||
use horizon_protocol::packets::play::{ChangeGameMode, Serverbound};
|
||||
use horizon_protocol::packets::play::ChangeGameMode;
|
||||
let mode = match mode {
|
||||
GameMode::Survival => 0,
|
||||
GameMode::Creative => 1,
|
||||
@@ -698,10 +700,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `KeyboardHandler.copyRecreateCommand`: the targeted block or mob as a
|
||||
/// `/setblock` or `/summon` command. In a single-player world the
|
||||
/// server's data (no Shift) is the game's own.
|
||||
/// `/setblock` or `/summon` command. The server's data (no Shift) comes
|
||||
/// from the server the game is connected to, the integrated one included
|
||||
/// (`DebugQueryHandler`); without one, the game's own data is the server's.
|
||||
fn copy_recreate_command(&mut self, add_nbt: bool, from_server: bool) {
|
||||
use horizon_client::java_text::format_fixed2 as f2;
|
||||
let query = add_nbt && from_server && self.remote.is_some();
|
||||
let side = if add_nbt && from_server {
|
||||
"server"
|
||||
} else {
|
||||
@@ -710,45 +713,135 @@ impl Game<'_> {
|
||||
if self.entity_targeted()
|
||||
&& let Some((index, _)) = self.mob_hit()
|
||||
{
|
||||
let (id, pos, mob) = {
|
||||
let (kind, position, id) = {
|
||||
let m = &self.mobs[index];
|
||||
(m.kind.id.clone(), m.position, m.id)
|
||||
};
|
||||
let mut command = format!("/summon {id} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
|
||||
if add_nbt && let Some(mut tag) = self.entity_nbt(super::commands::Target::Mob(mob)) {
|
||||
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
|
||||
for key in ["id", "UUID", "Pos"] {
|
||||
c.remove(&key.into());
|
||||
}
|
||||
if query {
|
||||
// DebugQueryHandler.queryEntityTag: the answer copies it.
|
||||
let reply = QueryReply::Entity { id, kind, position };
|
||||
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply))
|
||||
{
|
||||
self.send(Serverbound::EntityTagQuery(EntityTagQuery {
|
||||
transaction,
|
||||
entity: id as i32,
|
||||
}));
|
||||
}
|
||||
command.push(' ');
|
||||
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
|
||||
return;
|
||||
}
|
||||
self.set_clipboard(&command);
|
||||
// Entity.saveWithoutId.
|
||||
let tag = if add_nbt {
|
||||
self.entity_nbt(super::commands::Target::Mob(id))
|
||||
.map(|mut tag| {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"id".into());
|
||||
}
|
||||
tag
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.copy_create_entity_command(&kind, position, tag);
|
||||
self.debug_translated(&format!("debug.inspect.{side}.entity"));
|
||||
return;
|
||||
}
|
||||
let Some(hit) = self.pick() else {
|
||||
return;
|
||||
};
|
||||
let p = hit.pos;
|
||||
let mut description = self.state_string(self.world.block(p[0], p[1], p[2]));
|
||||
if add_nbt && let Some(e) = self.world.block_entities.get(&(p[0], p[1], p[2])) {
|
||||
// BlockEntity.saveWithoutMetadata: no id or position.
|
||||
let name = self.data_block_name(p);
|
||||
let mut tag = horizon_client::save::encode_block_entity((p[0], p[1], p[2]), e, &name);
|
||||
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
|
||||
for key in ["id", "x", "y", "z", "keepPacked"] {
|
||||
c.remove(&key.into());
|
||||
}
|
||||
let pos = hit.pos;
|
||||
let state = self.state_string(self.world.block(pos[0], pos[1], pos[2]));
|
||||
if query {
|
||||
// DebugQueryHandler.queryBlockEntityTag: the answer copies it.
|
||||
let reply = QueryReply::Block { pos, state };
|
||||
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply)) {
|
||||
self.send(Serverbound::BlockEntityTagQuery(BlockEntityTagQuery {
|
||||
transaction,
|
||||
pos,
|
||||
}));
|
||||
}
|
||||
description.push_str(&horizon_client::snbt::to_snbt(&tag));
|
||||
return;
|
||||
}
|
||||
let tag = if add_nbt {
|
||||
self.block_entity_tag(pos)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.copy_create_block_command(pos, &state, tag.as_ref());
|
||||
self.debug_translated(&format!("debug.inspect.{side}.block"));
|
||||
}
|
||||
/// `BlockEntity.saveWithoutMetadata` of the block entity at `pos`, if
|
||||
/// there is one: its data without the id or the position.
|
||||
pub(super) fn block_entity_tag(&self, pos: [i32; 3]) -> Option<Tag> {
|
||||
let e = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))?;
|
||||
let name = self.data_block_name(pos);
|
||||
let mut tag = horizon_client::save::encode_block_entity((pos[0], pos[1], pos[2]), e, &name);
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
for key in ["id", "x", "y", "z", "keepPacked"] {
|
||||
c.remove(&key.into());
|
||||
}
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
/// `ClientPacketListener.handleTagQueryPacket`: the server's answer to
|
||||
/// the last debug query (`DebugQueryHandler.handleResponse`) completes
|
||||
/// it; an answer to an older query is ignored.
|
||||
pub(super) fn answer_tag_query(&mut self, transaction: i32, tag: Tag) {
|
||||
let Some(reply) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.debug_query.answer(transaction))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// FriendlyByteBuf.readNbt: an End tag is no compound.
|
||||
let tag = match tag {
|
||||
Tag::End => None,
|
||||
tag => Some(tag),
|
||||
};
|
||||
match reply {
|
||||
QueryReply::Block { pos, state } => {
|
||||
self.copy_create_block_command(pos, &state, tag.as_ref());
|
||||
self.debug_translated("debug.inspect.server.block");
|
||||
}
|
||||
QueryReply::Entity { id, kind, position } => {
|
||||
// Entity.position() when the answer comes: where it is now,
|
||||
// else where it was last.
|
||||
let position = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or(position, |m| m.position);
|
||||
self.copy_create_entity_command(&kind, position, tag);
|
||||
self.debug_translated("debug.inspect.server.entity");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `KeyboardHandler.copyCreateBlockCommand`.
|
||||
fn copy_create_block_command(&mut self, pos: [i32; 3], state: &str, tag: Option<&Tag>) {
|
||||
let mut description = state.to_owned();
|
||||
if let Some(tag) = tag {
|
||||
description.push_str(&horizon_client::snbt::to_snbt(tag));
|
||||
}
|
||||
self.set_clipboard(&format!(
|
||||
"/setblock {} {} {} {description}",
|
||||
p[0], p[1], p[2]
|
||||
pos[0], pos[1], pos[2]
|
||||
));
|
||||
self.debug_translated(&format!("debug.inspect.{side}.block"));
|
||||
}
|
||||
/// `KeyboardHandler.copyCreateEntityCommand`: the data without its UUID
|
||||
/// or position.
|
||||
fn copy_create_entity_command(&mut self, kind: &str, pos: Vec3, tag: Option<Tag>) {
|
||||
use horizon_client::java_text::format_fixed2 as f2;
|
||||
let mut command = format!("/summon {kind} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
|
||||
if let Some(mut tag) = tag {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
for key in ["UUID", "Pos"] {
|
||||
c.remove(&key.into());
|
||||
}
|
||||
}
|
||||
command.push(' ');
|
||||
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
|
||||
}
|
||||
self.set_clipboard(&command);
|
||||
}
|
||||
/// `KeyboardHandler.setClipboard`.
|
||||
fn set_clipboard(&mut self, text: &str) {
|
||||
@@ -766,8 +859,8 @@ impl Game<'_> {
|
||||
p.position.y.floor() as i32,
|
||||
p.position.z.floor() as i32,
|
||||
];
|
||||
let min_y = horizon_client::chunk::MIN_Y;
|
||||
g.chunk_borders(block, min_y, min_y + horizon_client::chunk::HEIGHT - 1);
|
||||
let min_y = self.world.bottom_y();
|
||||
g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1);
|
||||
}
|
||||
if !self.show_hitboxes {
|
||||
return g;
|
||||
@@ -1010,3 +1103,78 @@ fn step_view(step: [i32; 3]) -> (f32, f32) {
|
||||
(-x.atan2(z)).to_degrees() as f32,
|
||||
)
|
||||
}
|
||||
|
||||
/// `DebugQueryHandler`: the last debug NBT query (F3+I) sent to the server
|
||||
/// and what its answer completes. Only the newest query is answered.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct DebugQuery {
|
||||
transaction: i32,
|
||||
pending: Option<QueryReply>,
|
||||
}
|
||||
impl Default for DebugQuery {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
transaction: -1,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What a debug query's answer is copied into: the block command
|
||||
/// (`copyCreateBlockCommand`) or the entity's (`copyCreateEntityCommand`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum QueryReply {
|
||||
Block {
|
||||
pos: [i32; 3],
|
||||
state: String,
|
||||
},
|
||||
Entity {
|
||||
id: u32,
|
||||
kind: String,
|
||||
position: Vec3,
|
||||
},
|
||||
}
|
||||
impl DebugQuery {
|
||||
/// `DebugQueryHandler.startTransaction`: the next transaction id, which
|
||||
/// replaces any query still waiting.
|
||||
fn start(&mut self, reply: QueryReply) -> i32 {
|
||||
self.pending = Some(reply);
|
||||
self.transaction = self.transaction.wrapping_add(1);
|
||||
self.transaction
|
||||
}
|
||||
/// `DebugQueryHandler.handleResponse`: what the answer to `transaction`
|
||||
/// completes, once, when it answers the newest query.
|
||||
fn answer(&mut self, transaction: i32) -> Option<QueryReply> {
|
||||
if transaction == self.transaction {
|
||||
self.pending.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn block(x: i32) -> QueryReply {
|
||||
QueryReply::Block {
|
||||
pos: [x, 64, 0],
|
||||
state: "minecraft:chest[facing=north,type=single,waterlogged=false]".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_debug_query_is_answered_once_and_only_the_newest() {
|
||||
let mut q = DebugQuery::default();
|
||||
// The first transaction is 0 (the handler starts at -1).
|
||||
assert_eq!(q.start(block(1)), 0);
|
||||
assert_eq!(q.answer(0), Some(block(1)));
|
||||
assert_eq!(q.answer(0), None);
|
||||
// A second press before the first answer: the older answer is ignored
|
||||
// and the newer one copies the newer target.
|
||||
assert_eq!(q.start(block(2)), 1);
|
||||
assert_eq!(q.start(block(3)), 2);
|
||||
assert_eq!(q.answer(1), None);
|
||||
assert_eq!(q.answer(2), Some(block(3)));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,26 @@ pub(super) fn redirectable(p: &Projectile) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// `Projectile.onRedirectProjectile`: another projectile hit `p`, which turns
|
||||
/// with `deflect(AIM_DEFLECT, hitter's owner, hitter's owner, true, 1.0)`.
|
||||
/// It flies along the owner's look (`Entity.getLookAngle`; unchanged when the
|
||||
/// owner is gone), is that owner's now, and its acceleration power is back
|
||||
/// to 0.1 (`AbstractHurtingProjectile.onDeflection` by an attack).
|
||||
pub(super) fn aim_deflect(
|
||||
p: &mut Projectile,
|
||||
look: Option<Vec3>,
|
||||
owner: Option<Who>,
|
||||
guest: Option<u32>,
|
||||
) {
|
||||
if let Some(look) = look {
|
||||
p.velocity = look;
|
||||
p.needs_sync = true;
|
||||
}
|
||||
p.owner = owner;
|
||||
p.guest = guest;
|
||||
p.acceleration = 0.1;
|
||||
}
|
||||
|
||||
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge
|
||||
/// 0.3125, from its feet up) grown by the pick radius.
|
||||
fn pick_box(p: &Projectile) -> Aabb {
|
||||
@@ -106,6 +126,27 @@ impl Game<'_> {
|
||||
d2 < range * range
|
||||
}
|
||||
|
||||
/// `Entity.getLookAngle` of a projectile's owner: the host, the guest who
|
||||
/// threw it, or a mob; none when the owner is not here.
|
||||
pub(super) fn owner_look(&self, owner: Option<Who>, guest: Option<u32>) -> Option<Vec3> {
|
||||
let (pitch, yaw) = match (owner?, guest) {
|
||||
(Who::Player, Some(conn)) => {
|
||||
let lan = self.lan.as_ref()?;
|
||||
let g = lan.hub.guests.iter().find(|g| g.conn == conn)?;
|
||||
(g.pitch, g.yaw)
|
||||
}
|
||||
(Who::Player, None) => {
|
||||
self.player.as_ref()?;
|
||||
(self.pitch, self.yaw)
|
||||
}
|
||||
(Who::Mob(id), _) => {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
(m.head_pitch, m.yaw)
|
||||
}
|
||||
};
|
||||
Some(horizon_world::view_direction::view_vector(pitch, yaw))
|
||||
}
|
||||
|
||||
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
|
||||
pub(super) fn deflect(&mut self, i: usize) {
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
@@ -156,4 +197,36 @@ mod tests {
|
||||
assert_eq!(b.min_position().x, -1.5);
|
||||
assert_eq!(b.max_position().y, 66.0);
|
||||
}
|
||||
|
||||
fn wind_charge() -> Projectile {
|
||||
let mut p = Projectile::new(
|
||||
ProjectileKind::WindCharge,
|
||||
Vec3::new(0.0, 70.0, 0.0),
|
||||
Some(Who::Mob(7)),
|
||||
);
|
||||
p.velocity = Vec3::new(0.0, 1.5, 0.0);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
|
||||
let mut p = wind_charge();
|
||||
let look = Vec3::new(0.0, -1.0, 0.0);
|
||||
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
|
||||
assert_eq!(p.velocity, look);
|
||||
assert_eq!(p.owner, Some(Who::Player));
|
||||
assert_eq!(p.guest, Some(3));
|
||||
assert_eq!(p.acceleration, 0.1);
|
||||
assert!(p.needs_sync);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
|
||||
let mut p = wind_charge();
|
||||
aim_deflect(&mut p, None, None, None);
|
||||
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
|
||||
assert_eq!(p.owner, None);
|
||||
assert_eq!(p.acceleration, 0.1);
|
||||
assert!(!p.needs_sync);
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,8 @@ impl Game<'_> {
|
||||
Some("end") => self.dim = super::Dim::End,
|
||||
_ => {}
|
||||
}
|
||||
// The level's build range follows its dimension, as `enter_dimension` sets it.
|
||||
self.world.shape = self.dim.shape();
|
||||
// The oracle level's clock stands at day time 6000.
|
||||
self.world.day_time = 6000;
|
||||
if let Some(rules) = case["rules"].as_object() {
|
||||
|
||||
@@ -1414,7 +1414,7 @@ impl Game<'_> {
|
||||
) -> Result<Dispensed> {
|
||||
let target = offset(pos, facing);
|
||||
let free = self.res.facts().is_air(self.block_at(target))
|
||||
&& target.1 >= horizon_client::chunk::MIN_Y + 2
|
||||
&& target.1 >= self.world.bottom_y() + 2
|
||||
&& self.ui.difficulty != 0
|
||||
&& self.can_build_wither(target);
|
||||
if free {
|
||||
|
||||
@@ -2887,6 +2887,7 @@ impl Game<'_> {
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ enum MendSlot {
|
||||
Armor(usize),
|
||||
}
|
||||
|
||||
/// `Player.getDestroySpeed` multiplies by `BLOCK_BREAK_SPEED`; a player
|
||||
/// without the attribute keeps the vanilla default of 1.0.
|
||||
fn block_break_speed_factor(value: Option<f64>) -> f32 {
|
||||
value.map_or(1.0, |value| value as f32)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
|
||||
/// and mining fatigue.
|
||||
@@ -39,6 +45,15 @@ impl Game<'_> {
|
||||
}
|
||||
speed * self.potions.effects.mining_speed_factor()
|
||||
}
|
||||
/// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor:
|
||||
/// 1.0 by default, so a missing player or attribute changes nothing.
|
||||
pub(super) fn block_break_speed(&mut self) -> f32 {
|
||||
block_break_speed_factor(
|
||||
self.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:block_break_speed").ok()),
|
||||
)
|
||||
}
|
||||
/// `Attributes.SUBMERGED_MINING_SPEED` (0.2, raised by aqua affinity).
|
||||
pub(super) fn submerged_mining_speed(&self) -> f32 {
|
||||
let mut total = 0.0;
|
||||
@@ -1012,3 +1027,19 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::block_break_speed_factor;
|
||||
|
||||
/// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed.
|
||||
#[test]
|
||||
fn block_break_speed_is_a_destroy_speed_multiplier() {
|
||||
assert_eq!(block_break_speed_factor(None), 1.0);
|
||||
assert_eq!(block_break_speed_factor(Some(1.0)), 1.0);
|
||||
assert_eq!(block_break_speed_factor(Some(0.5)), 0.5);
|
||||
assert_eq!(block_break_speed_factor(Some(2.0)), 2.0);
|
||||
assert_eq!(8.0 * block_break_speed_factor(Some(0.5)), 4.0);
|
||||
assert_eq!(8.0 * block_break_speed_factor(Some(2.0)), 16.0);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ use horizon_client::{
|
||||
environment::Environment,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::Who,
|
||||
motion::PlayerMotion,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
vanillagen::VanillaTerrain,
|
||||
world_spawn::PlayerSpawnFinder,
|
||||
};
|
||||
@@ -29,6 +31,9 @@ fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
|
||||
/// `TicketType.PORTAL`'s timeout, in ticks.
|
||||
const PORTAL_TICKET: u32 = 300;
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
@@ -47,18 +52,27 @@ pub(super) enum GatewayJob {
|
||||
Known { exit: [i32; 3], exact: bool },
|
||||
}
|
||||
|
||||
/// An entity travelling through an End gateway: its saved compound and the
|
||||
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
|
||||
/// An entity travelling through an End gateway: the entity and the job that
|
||||
/// finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct GatewayArrival {
|
||||
/// The entity's compound, as it waits for the exit's chunks.
|
||||
tag: Tag,
|
||||
/// The entity, as it waits for the exit's chunks.
|
||||
traveller: Arriving,
|
||||
/// The gateway's exit, or the search for one.
|
||||
job: GatewayJob,
|
||||
/// The spot to load before the job is resolved.
|
||||
target: Vec3,
|
||||
}
|
||||
|
||||
/// What waits at a gateway's exit.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Arriving {
|
||||
/// An item or a mob, as its saved compound.
|
||||
Saved(Tag),
|
||||
/// A thrown ender pearl (projectiles are not saved).
|
||||
Pearl(Box<Projectile>),
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
@@ -120,6 +134,10 @@ pub(super) struct EndState {
|
||||
arrivals: Vec<GatewayArrival>,
|
||||
/// The chunks kept loaded for those arrivals.
|
||||
areas: Vec<(i32, i32)>,
|
||||
/// `Entity.placePortalTicket` of the pearls that came out of a gateway:
|
||||
/// the chunk each landed in, kept loaded for the ticks left of its
|
||||
/// `TicketType.PORTAL`, so that it can come down and take its owner there.
|
||||
tickets: Vec<((i32, i32), u32)>,
|
||||
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
|
||||
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
|
||||
/// Thrown eyes of ender in flight.
|
||||
@@ -1462,13 +1480,17 @@ impl Game<'_> {
|
||||
|
||||
// ---- End gateway travel for the entities ----
|
||||
|
||||
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
|
||||
/// gateway blocks their box swept through. A gateway that is cooling down
|
||||
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
|
||||
/// `EndGatewayBlock.entityInside` for the items, mobs and thrown ender
|
||||
/// pearls in the End: the gateway blocks their box swept through. A
|
||||
/// gateway that is cooling down (`TheEndGatewayBlockEntity.triggerCooldown`)
|
||||
/// takes nobody in.
|
||||
pub(super) fn find_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let is_gateway = |game: &Self, p: BlockPos| {
|
||||
&*game.res.facts().get(game.world.block(p.x, p.y, p.z)).name == "minecraft:end_gateway"
|
||||
};
|
||||
let items: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
@@ -1484,13 +1506,28 @@ impl Game<'_> {
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
|
||||
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
|
||||
== "minecraft:end_gateway"
|
||||
is_gateway(self, p)
|
||||
});
|
||||
if let Some(pos) = found {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
// A pearl's move this tick, from where it started it.
|
||||
let pearls: Vec<(u32, Option<Who>, Option<u32>, BlockPos)> = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let pos = horizon_client::end_gateway::pearl_gateway(p, |b| is_gateway(self, b))?;
|
||||
Some((p.id, p.owner, p.guest, pos))
|
||||
})
|
||||
.collect();
|
||||
for (id, owner, guest, pos) in pearls {
|
||||
if owner == Some(Who::Player) {
|
||||
self.pearl_owner_in_gateway(guest, pos);
|
||||
}
|
||||
inside.push((id, pos));
|
||||
}
|
||||
for (id, pos) in inside {
|
||||
// One entity at a time: the gateway cools down for 40 ticks.
|
||||
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
|
||||
@@ -1504,6 +1541,22 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's
|
||||
/// `ServerPlayer.onInsideBlock`, the enter_block trigger, as if they had
|
||||
/// walked in. A guest's pearl triggers theirs; one whose guest has left
|
||||
/// has no owner.
|
||||
fn pearl_owner_in_gateway(&mut self, guest: Option<u32>, pos: BlockPos) {
|
||||
let state = self.world.block(pos.x, pos.y, pos.z);
|
||||
let Some(conn) = guest else {
|
||||
self.trigger_enter_block(state);
|
||||
return;
|
||||
};
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.as_guest(&mut host, conn, |g| g.trigger_enter_block(state));
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` for the entities in an End gateway: they go
|
||||
/// through to the gateway's exit, where they wait for its chunks.
|
||||
pub(super) fn handle_end_gateway_portals(&mut self) {
|
||||
@@ -1515,8 +1568,20 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.chain(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.filter(|p| matches!(p.kind, ProjectileKind::EnderPearl))
|
||||
.map(|p| (p.id, true)),
|
||||
)
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
let mut present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
// A pearl waiting for its exit keeps its portal cooldown.
|
||||
present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller {
|
||||
Arriving::Pearl(p) => Some(p.id),
|
||||
Arriving::Saved(_) => None,
|
||||
}));
|
||||
self.end.gateways.retain(|id, _| present.contains(id));
|
||||
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, allowed) in ids {
|
||||
@@ -1573,11 +1638,28 @@ impl Game<'_> {
|
||||
self.forget_entity(g);
|
||||
}
|
||||
tag
|
||||
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
|
||||
let pearl = Box::new(self.creatures.projectiles.remove(i));
|
||||
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
|
||||
let arrival = GatewayArrival {
|
||||
traveller: Arriving::Pearl(pearl),
|
||||
job,
|
||||
target,
|
||||
};
|
||||
// It is at the exit before it moves this tick, once that has loaded.
|
||||
if let Some(waiting) = self.arrive_through_gateway(arrival) {
|
||||
self.end.arrivals.push(waiting);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
|
||||
self.end.arrivals.push(GatewayArrival { tag, job, target });
|
||||
self.end.arrivals.push(GatewayArrival {
|
||||
traveller: Arriving::Saved(tag),
|
||||
job,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
/// The entities that reached a gateway's exit: they land where the
|
||||
@@ -1586,47 +1668,60 @@ impl Game<'_> {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return Ok(());
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.end.arrivals.len() {
|
||||
let (cx, cz) = (
|
||||
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
|
||||
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
|
||||
let at = match self.resolve_gateway(job, target) {
|
||||
Ok(at) => at,
|
||||
Err(e) => {
|
||||
// The traveller waits for the exit: the failure is retried.
|
||||
log::warn!("End gateway: {e}");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut tag = self.end.arrivals.remove(i).tag;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
|
||||
// setPortalCooldown before the teleport.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
match self
|
||||
.world
|
||||
.loaded_entities
|
||||
.iter_mut()
|
||||
.find(|(k, _)| *k == key)
|
||||
{
|
||||
Some((_, tags)) => tags.push(tag),
|
||||
None => self.world.loaded_entities.push((key, vec![tag])),
|
||||
for arrival in std::mem::take(&mut self.end.arrivals) {
|
||||
if let Some(waiting) = self.arrive_through_gateway(arrival) {
|
||||
self.end.arrivals.push(waiting);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Entity.teleport` of a gateway traveller once its exit's chunk has
|
||||
/// loaded: an item or a mob joins the chunk it lands in, a pearl flies
|
||||
/// on from there. The traveller comes back while it still waits.
|
||||
fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option<GatewayArrival> {
|
||||
let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
if !self.world.chunks.contains_key(&chunk(arrival.target)) {
|
||||
return Some(arrival);
|
||||
}
|
||||
let at = match self.resolve_gateway(arrival.job, arrival.target) {
|
||||
Ok(at) => at,
|
||||
Err(e) => {
|
||||
// The traveller waits for the exit: the failure is retried.
|
||||
log::warn!("End gateway: {e}");
|
||||
return Some(arrival);
|
||||
}
|
||||
};
|
||||
let key = chunk(at);
|
||||
match arrival.traveller {
|
||||
Arriving::Saved(mut tag) => {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
|
||||
// setPortalCooldown before the teleport.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
match self
|
||||
.world
|
||||
.loaded_entities
|
||||
.iter_mut()
|
||||
.find(|(k, _)| *k == key)
|
||||
{
|
||||
Some((_, tags)) => tags.push(tag),
|
||||
None => self.world.loaded_entities.push((key, vec![tag])),
|
||||
}
|
||||
}
|
||||
Arriving::Pearl(mut pearl) => {
|
||||
horizon_client::end_gateway::place_pearl(&mut pearl, at);
|
||||
// ThrownEnderpearl.teleport: placePortalTicket where it came out.
|
||||
self.end.tickets.retain(|(c, _)| *c != key);
|
||||
self.end.tickets.push((key, PORTAL_TICKET));
|
||||
self.add_projectile(*pearl);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The chunks the entities waiting to arrive need, kept loaded like the
|
||||
/// areas a server keeps around its players.
|
||||
pub(super) fn keep_gateway_destinations(&mut self) {
|
||||
@@ -1634,12 +1729,24 @@ impl Game<'_> {
|
||||
self.streaming
|
||||
.extra
|
||||
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
|
||||
// The pearls' portal tickets hold End chunks, and run out there
|
||||
// (TicketType.PORTAL's timeout).
|
||||
let in_end = self.dim == Dim::End;
|
||||
if in_end {
|
||||
self.end.tickets.retain_mut(|(_, ticks)| {
|
||||
*ticks = ticks.saturating_sub(1);
|
||||
*ticks > 0
|
||||
});
|
||||
}
|
||||
let mut areas = Vec::new();
|
||||
for a in &self.end.arrivals {
|
||||
let c = (
|
||||
let arrivals = self.end.arrivals.iter().map(|a| {
|
||||
(
|
||||
(a.target.x.floor() as i32) >> 4,
|
||||
(a.target.z.floor() as i32) >> 4,
|
||||
);
|
||||
)
|
||||
});
|
||||
let tickets = self.end.tickets.iter().filter(|_| in_end);
|
||||
for c in arrivals.chain(tickets.map(|&(c, _)| c)) {
|
||||
if !areas.contains(&c) {
|
||||
areas.push(c);
|
||||
self.streaming.extra.push((c, 1));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Entity occlusion affects mesh submission, never entity ticking or interaction.
|
||||
use super::{Game, Vertex};
|
||||
use horizon_client::chunk::MIN_Y;
|
||||
use horizon_client::{
|
||||
blocks::{RenderShape, StateFacts},
|
||||
entity_culling::Culler,
|
||||
@@ -41,7 +40,7 @@ impl Game<'_> {
|
||||
if !opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
let Some(y) = pos[1].checked_sub(MIN_Y) else {
|
||||
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
|
||||
return false;
|
||||
};
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
|
||||
@@ -89,6 +89,29 @@ pub(super) enum BlockInteraction {
|
||||
DestroyWithDecay,
|
||||
}
|
||||
|
||||
/// `ServerExplosion.hurtEntities`: an entity is hurt when its position is
|
||||
/// within the blast's diameter of the centre.
|
||||
pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bool {
|
||||
let d =
|
||||
((at[0] - center[0]).powi(2) + (at[1] - center[1]).powi(2) + (at[2] - center[2]).powi(2))
|
||||
.sqrt();
|
||||
d / diameter <= 1.0
|
||||
}
|
||||
|
||||
/// Whether a blast leaves item frames, paintings, cushions and armour stands
|
||||
/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored
|
||||
/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their
|
||||
/// `ignoreExplosion`; no wind charge goes through `server_explode`), and one a
|
||||
/// mob causes does no harm (`BlockAttachedEntity.hurtServer` and
|
||||
/// `ArmorStand.hurtServer`: `source.getEntity() instanceof Mob`).
|
||||
pub(super) fn blast_spares_decorations(
|
||||
mob_griefing: bool,
|
||||
interaction: BlockInteraction,
|
||||
by_mob: bool,
|
||||
) -> bool {
|
||||
!mob_griefing && (interaction == BlockInteraction::Keep || by_mob)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Client-side randomness (explosion sound pitch), separate from the level random.
|
||||
static CLIENT_RANDOM: std::cell::RefCell<LegacyRandom> = std::cell::RefCell::new(LegacyRandom::new(0x5eed));
|
||||
@@ -446,7 +469,6 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.mobs = mobs;
|
||||
self.explosion_breaks_frames(center, diameter, interaction);
|
||||
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| {
|
||||
@@ -525,6 +547,73 @@ impl Game<'_> {
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
// 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.
|
||||
self.explosion_breaks_frames(center, diameter, interaction);
|
||||
self.explosion_breaks_decorations(center, diameter, interaction);
|
||||
}
|
||||
/// `blast_spares_decorations` for the blast going off now: the mob that
|
||||
/// owns its projectile or explodes itself is the source's entity.
|
||||
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
|
||||
let by_mob = self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(self.combat.explosion_source)
|
||||
.is_some();
|
||||
blast_spares_decorations(self.ui.rules.bool("mob_griefing"), interaction, by_mob)
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for paintings, cushions and armour
|
||||
/// stands (item frames: `explosion_breaks_frames`). A painting or cushion
|
||||
/// in reach is killed and dropped (`BlockAttachedEntity.hurtServer`,
|
||||
/// `Painting.dropItem`, `Cushion.dropItem`). An armour stand that is
|
||||
/// neither invisible nor a marker breaks (`ArmorStand.hurtServer` with
|
||||
/// `IS_EXPLOSION`: `brokenByAnything`, then `kill`): its equipment drops,
|
||||
/// the stand itself does not.
|
||||
/// Not ported: `BlockAttachedEntity.ignoreExplosion` also spares paintings
|
||||
/// and cushions when the exploding entity is in water.
|
||||
fn explosion_breaks_decorations(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
diameter: f64,
|
||||
interaction: BlockInteraction,
|
||||
) {
|
||||
if self.blast_spares_decorations(interaction) {
|
||||
return;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.paintings.len() {
|
||||
// HangingEntity.recalculateBoundingBox: a painting stands at the
|
||||
// centre of its box.
|
||||
let (lo, hi) = self.paintings[i].bounds();
|
||||
let at = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
|
||||
if blast_reaches(center, diameter, at) {
|
||||
self.break_painting(i, false);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.cushions.len() {
|
||||
let p = self.cushions[i].position;
|
||||
if blast_reaches(center, diameter, [p.x, p.y, p.z]) {
|
||||
self.break_cushion(i, false);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.armor_stands.len() {
|
||||
let a = &self.armor_stands[i];
|
||||
let p = a.position;
|
||||
if !super::armor_stands::spared_by_blast(a)
|
||||
&& blast_reaches(center, diameter, [p.x, p.y, p.z])
|
||||
{
|
||||
self.break_armor_stand_by_anything(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying
|
||||
/// and plain destruction.
|
||||
@@ -758,6 +847,29 @@ fn add_or_append_stack(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blasts_reach_entities_within_their_diameter() {
|
||||
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
|
||||
let center = [0.5, 64.0, 0.5];
|
||||
assert!(blast_reaches(center, 8.0, [8.5, 64.0, 0.5]));
|
||||
assert!(blast_reaches(center, 8.0, [-3.5, 68.0, 4.5]));
|
||||
assert!(!blast_reaches(center, 8.0, [8.6, 64.0, 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mob_griefing_off_spares_decorations_from_kept_and_mob_blasts() {
|
||||
use BlockInteraction::{Destroy, DestroyWithDecay, Keep};
|
||||
// mobGriefing on: every blast breaks them, a creeper's included.
|
||||
assert!(!blast_spares_decorations(true, DestroyWithDecay, true));
|
||||
assert!(!blast_spares_decorations(true, Keep, true));
|
||||
// Off: TNT still breaks them (ExplosionInteraction.TNT destroys blocks).
|
||||
assert!(!blast_spares_decorations(false, Destroy, false));
|
||||
assert!(!blast_spares_decorations(false, DestroyWithDecay, false));
|
||||
// Off: a blast that keeps blocks, or one a mob causes, does not.
|
||||
assert!(blast_spares_decorations(false, Keep, false));
|
||||
assert!(blast_spares_decorations(false, Destroy, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_pos_hash_matches_vec3i() {
|
||||
// Vec3i.hashCode: (y + z * 31) * 31 + x
|
||||
|
||||
@@ -79,6 +79,14 @@ impl Game<'_> {
|
||||
.fov_player()
|
||||
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
|
||||
let target = self.zoomed_fov_target(target);
|
||||
// A server's spectator camera on a mob: its eye height. Its modifier
|
||||
// stays 1 (another player's own modifier is not modelled here); the
|
||||
// zoom extra still narrows it.
|
||||
if let Some(m) = self.camera_mob() {
|
||||
let eye = m.eye_height() as f32;
|
||||
self.view.tick(Some(eye), self.zoomed_fov_target(1.0));
|
||||
return;
|
||||
}
|
||||
let eye = self.player.as_ref().map(|p| {
|
||||
if self.sleeping.is_some() {
|
||||
0.2
|
||||
|
||||
@@ -298,9 +298,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
|
||||
/// is killed and dropped with its item (`BlockAttachedEntity.hurtServer`).
|
||||
/// A fixed frame is not hurt by an explosion. Without `mobGriefing` a blast
|
||||
/// that keeps blocks (`ServerExplosion.shouldAffectBlocklikeEntities` through
|
||||
/// `BlockAttachedEntity.ignoreExplosion`) and a mob's blast spare frames.
|
||||
/// A fixed frame is not hurt by an explosion, and `blast_spares_decorations`
|
||||
/// keeps every frame without `mobGriefing`.
|
||||
/// Not ported: `ignoreExplosion` also spares frames when the exploding
|
||||
/// entity is in water.
|
||||
pub(super) fn explosion_breaks_frames(
|
||||
@@ -309,10 +308,7 @@ impl Game<'_> {
|
||||
diameter: f64,
|
||||
interaction: super::explosion::BlockInteraction,
|
||||
) {
|
||||
if !self.ui.rules.bool("mob_griefing")
|
||||
&& (interaction == super::explosion::BlockInteraction::Keep
|
||||
|| self.combat.explosion_source.is_some())
|
||||
{
|
||||
if self.blast_spares_decorations(interaction) {
|
||||
return;
|
||||
}
|
||||
let mut i = 0;
|
||||
|
||||
@@ -58,7 +58,53 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
|
||||
distance >= f64::from(min - margin) && distance <= f64::from(max + margin)
|
||||
}
|
||||
|
||||
/// `FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem`'s
|
||||
/// `ItemStack.matchesIgnoringComponents(visible, held,
|
||||
/// DataComponentType::ignoreSwapAnimation)` for one hand (every item plays
|
||||
/// the swap animation, `ItemModelResolver.shouldPlaySwapAnimation`): the same
|
||||
/// stack object, or an equal one, whose damage (the one component built
|
||||
/// with `ignoreSwapAnimation`) may differ.
|
||||
///
|
||||
/// A stack that stays in its slot with the same item is the same object,
|
||||
/// whatever its count or damage (vanilla shrinks, grows and damages it in
|
||||
/// place), unless the slot was `replaced` by one the hand doesn't show yet:
|
||||
/// swapping the hands (`setItemInHand`, and the server's slot updates that
|
||||
/// answer it) puts another object in both (#2866).
|
||||
fn shows_held_stack(
|
||||
visible: &(usize, Option<ItemStack>),
|
||||
held: &(usize, Option<ItemStack>),
|
||||
replaced: bool,
|
||||
) -> bool {
|
||||
let same_object = visible.0 == held.0 && !replaced;
|
||||
match (&visible.1, &held.1) {
|
||||
(Some(a), Some(b)) if same_object => a.item == b.item,
|
||||
(a, b) => matches_for_swap(a.as_ref(), b.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ItemStack.matchesIgnoringComponents(a, b,
|
||||
/// DataComponentType::ignoreSwapAnimation)`: the same count, item and
|
||||
/// components but damage.
|
||||
pub(super) fn matches_for_swap(a: Option<&ItemStack>, b: Option<&ItemStack>) -> bool {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => a.count == b.count && a.item == b.item && a.extra == b.extra,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A slot of the inventory (`OFFHAND_SLOT` for the off hand) was set to
|
||||
/// a new stack: the hand holding it compares it as another `ItemStack`
|
||||
/// (`shows_held_stack`) until the hand shows the held stack again.
|
||||
pub(super) fn hand_stack_replaced(&mut self, slot: usize) {
|
||||
if slot == self.selected {
|
||||
self.hands_replaced[0] = true;
|
||||
}
|
||||
if slot == super::shield::OFFHAND_SLOT {
|
||||
self.hands_replaced[1] = true;
|
||||
}
|
||||
}
|
||||
/// Opening a screen stops breaking before the next game tick (which may pause).
|
||||
pub(super) fn stop_mining(&mut self) {
|
||||
if self.clicks.destroying.active {
|
||||
@@ -155,7 +201,8 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
|
||||
pub(super) fn destroy_progress(&self, pos: [i32; 3]) -> f32 {
|
||||
pub(super) fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
|
||||
let block_break_speed = self.block_break_speed();
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let facts = self.res.facts().get(state);
|
||||
if facts.hardness == -1.0 {
|
||||
@@ -164,6 +211,7 @@ impl Game<'_> {
|
||||
let held = self.held();
|
||||
let mut speed = self.items().mining_speed(held, &facts.name);
|
||||
speed = self.modify_destroy_speed(speed);
|
||||
speed *= block_break_speed;
|
||||
if let Some(eye) = self.eye_position()
|
||||
&& self
|
||||
.res
|
||||
@@ -266,22 +314,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.attack_item = main.as_ref().map(|s| s.item.clone());
|
||||
let next = [(self.selected, main), (OFFHAND, self.offhand.clone())];
|
||||
// shouldInstantlyReplaceVisibleItem: the same stack, or an equal one
|
||||
// (matchesIgnoringComponents; every item plays the swap animation).
|
||||
// A stack that stays in its slot with the same item is the same
|
||||
// stack, whatever its count (vanilla shrinks and grows it in place).
|
||||
let mut changed = [false; 2];
|
||||
for h in 0..2 {
|
||||
let same = match (&self.visible_hands[h], &next[h]) {
|
||||
((_, None), (_, None)) => true,
|
||||
((slot, Some(a)), (next_slot, Some(b))) => {
|
||||
a.item == b.item
|
||||
&& (slot == next_slot || (a.count == b.count && a.extra == b.extra))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let replaced = self.hands_replaced[h];
|
||||
let same = shows_held_stack(&self.visible_hands[h], &next[h], replaced);
|
||||
if same {
|
||||
self.visible_hands[h] = next[h].clone();
|
||||
self.hands_replaced[h] = false;
|
||||
}
|
||||
changed[h] = !same;
|
||||
}
|
||||
@@ -294,6 +333,7 @@ impl Game<'_> {
|
||||
for h in 0..2 {
|
||||
if replace[h] {
|
||||
self.visible_hands[h] = next[h].clone();
|
||||
self.hands_replaced[h] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,7 +458,21 @@ impl Game<'_> {
|
||||
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
|
||||
/// the attribute's range.
|
||||
pub(super) fn main_hand_attack_speed(&self) -> f64 {
|
||||
let value = horizon_client::attack_strength::BASE_ATTACK_SPEED
|
||||
// The base comes from the player's attribute map so `/attribute ... base set` applies.
|
||||
let base = self
|
||||
.player
|
||||
.as_ref()
|
||||
.and_then(|player| {
|
||||
let key = player.attributes.key("minecraft:attack_speed")?;
|
||||
let handle = player.attributes.lookup(Some(&key))?;
|
||||
player
|
||||
.attributes
|
||||
.instance(&handle)
|
||||
.ok()
|
||||
.map(|value| value.base_value())
|
||||
})
|
||||
.unwrap_or(horizon_client::attack_strength::BASE_ATTACK_SPEED);
|
||||
let value = base
|
||||
+ self
|
||||
.attack_item_info()
|
||||
.map_or(0.0, |i| f64::from(i.attack_speed));
|
||||
@@ -569,6 +623,8 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
true
|
||||
}
|
||||
fn attack_block(&mut self, pos: [i32; 3]) {
|
||||
// RedStoneOreBlock.interact: the dust comes whether or not the ore was lit.
|
||||
self.game.redstone_ore_particles(pos);
|
||||
let result = self.game.attack_block(pos);
|
||||
self.keep(result);
|
||||
}
|
||||
@@ -661,6 +717,52 @@ mod tests {
|
||||
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
|
||||
}
|
||||
|
||||
fn stack(item: &str, count: u32, damage: u32) -> Option<ItemStack> {
|
||||
Some(ItemStack {
|
||||
item: item.into(),
|
||||
count,
|
||||
damage,
|
||||
extra: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swapped_in_stack_of_the_same_item_re_equips() {
|
||||
// #2866: F swaps 64 torches into the off hand that held 32. The off
|
||||
// hand's slot stays, but the server's slot update puts another stack
|
||||
// object there; a different count fails matchesIgnoringComponents,
|
||||
// so the hand plays the swap animation.
|
||||
let visible = (OFFHAND, stack("minecraft:torch", 32, 0));
|
||||
let held = (OFFHAND, stack("minecraft:torch", 64, 0));
|
||||
assert!(!shows_held_stack(&visible, &held, true));
|
||||
// The same stack shrunk in place (a torch placed): no animation.
|
||||
assert!(shows_held_stack(&visible, &held, false));
|
||||
// An equal stack replaced it: shown at once.
|
||||
assert!(shows_held_stack(&visible, &visible.clone(), true));
|
||||
// DataComponents.DAMAGE ignores the swap animation: a sword with
|
||||
// other damage swapped in shows at once.
|
||||
let sword = (OFFHAND, stack("minecraft:iron_sword", 1, 3));
|
||||
let worn = (OFFHAND, stack("minecraft:iron_sword", 1, 40));
|
||||
assert!(shows_held_stack(&sword, &worn, true));
|
||||
// Another component (an anvil's repair cost) differs.
|
||||
let mut named = worn.clone();
|
||||
named.1.as_mut().unwrap().extra = Some(Box::new(horizon_client::item_data::StackData {
|
||||
repair_cost: 1,
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(!shows_held_stack(&sword, &named, true));
|
||||
// The main hand changing hotbar slot to an equal stack: instant; to
|
||||
// a different count: animation.
|
||||
let (a, b) = (
|
||||
(0, stack("minecraft:torch", 32, 0)),
|
||||
(1, stack("minecraft:torch", 32, 0)),
|
||||
);
|
||||
assert!(shows_held_stack(&a, &b, false));
|
||||
let c = (1, stack("minecraft:torch", 5, 0));
|
||||
assert!(!shows_held_stack(&a, &c, false));
|
||||
assert!(shows_held_stack(&(0, None), &(OFFHAND, None), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_range_uses_inclusive_float_bounds() {
|
||||
let range = serde_json::json!({
|
||||
|
||||
@@ -6,7 +6,6 @@ use super::{Game, dispenser::entity_random};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Properties,
|
||||
chunk::{HEIGHT, MIN_Y},
|
||||
collisions::{SceneCollisions, no_collision_or_liquid},
|
||||
world::World,
|
||||
world_spawn::{
|
||||
@@ -32,13 +31,13 @@ struct Columns<'a, 'r> {
|
||||
}
|
||||
impl SpawnLevel for Columns<'_, '_> {
|
||||
fn min_y(&self) -> i32 {
|
||||
MIN_Y
|
||||
self.world.bottom_y()
|
||||
}
|
||||
fn height(&self, kind: HeightmapType, x: i32, z: i32) -> i32 {
|
||||
(MIN_Y..MIN_Y + HEIGHT)
|
||||
(self.world.bottom_y()..self.world.top_y())
|
||||
.rev()
|
||||
.find(|&y| kind.is_opaque(self.properties.heightmap_state(self.world.block(x, y, z))))
|
||||
.map_or(MIN_Y, |y| y + 1)
|
||||
.map_or(self.world.bottom_y(), |y| y + 1)
|
||||
}
|
||||
fn has_fluid(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
use horizon_world::heightmap::HeightmapState;
|
||||
@@ -347,11 +346,11 @@ impl Game<'_> {
|
||||
/// down onto the ground.
|
||||
fn fixup_spawn_height(&self, pos: [i32; 3]) -> Vec3 {
|
||||
let [x, mut y, z] = pos;
|
||||
while !self.player_fits([x, y, z]) && y < MIN_Y + HEIGHT - 1 {
|
||||
while !self.player_fits([x, y, z]) && y < self.world.top_y() - 1 {
|
||||
y += 1;
|
||||
}
|
||||
y -= 1;
|
||||
while self.player_fits([x, y, z]) && y > MIN_Y {
|
||||
while self.player_fits([x, y, z]) && y > self.world.bottom_y() {
|
||||
y -= 1;
|
||||
}
|
||||
bottom_center([x, y + 1, z])
|
||||
|
||||
@@ -309,32 +309,28 @@ impl Game<'_> {
|
||||
batch,
|
||||
x,
|
||||
y,
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
self.settings.file.double("glintStrength") as f32,
|
||||
self.glint_frame.millis / 1000.0,
|
||||
self.glint_frame.speed,
|
||||
self.glint_frame.alpha,
|
||||
);
|
||||
}
|
||||
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
|
||||
pub(super) fn glint(&self) -> Option<item_render::Glint> {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get("misc/enchanted_glint_item")
|
||||
.copied()
|
||||
.map(|_| item_render::Glint {
|
||||
speed: self.settings.file.double("glintSpeed") as f32,
|
||||
alpha: self.settings.file.double("glintStrength") as f32,
|
||||
millis: self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
scale: 0.5,
|
||||
})
|
||||
/// The glint clock, options and sprite for this frame: read once per
|
||||
/// frame, not per item (`GameRenderer.extractOptions`).
|
||||
pub(super) fn capture_glint_frame(&self) -> item_render::GlintFrame {
|
||||
item_render::GlintFrame {
|
||||
millis: self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
speed: self.settings.file.double("glintSpeed") as f32,
|
||||
alpha: self.settings.file.double("glintStrength") as f32,
|
||||
sprite: self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.contains_key("misc/enchanted_glint_item"),
|
||||
}
|
||||
}
|
||||
/// The glint scroll phase shared by world items and armour
|
||||
/// (`item_render::Glint::scroll_ticks`).
|
||||
pub(super) fn glint_ticks(&self) -> f32 {
|
||||
item_render::Glint::scroll_ticks(
|
||||
self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
)
|
||||
/// The `ENTITY_GLINT_TEXTURING` inputs of this frame.
|
||||
pub(super) fn glint(&self) -> Option<item_render::Glint> {
|
||||
self.glint_frame.glint(0.5)
|
||||
}
|
||||
/// Submit a resolved item under `pose` (camera-relative) at `light`
|
||||
/// (packed lightmap coordinates).
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
item_render::stack_view,
|
||||
items::ItemStack,
|
||||
particles::{self, ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
use serde_json::Value;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ItemParticleOption(ITEM, stack)`: the layer particle sprites of the
|
||||
@@ -36,6 +40,75 @@ impl Game<'_> {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.handleEntityEvent` 47 to 52, 65 and 68 from a server:
|
||||
/// the entity's item in `slot` broke, so `breakItem` the stack this
|
||||
/// client still has there (the slot is emptied by a later update).
|
||||
pub(super) fn remote_equipment_break(&mut self, id: i32, slot: EquipmentSlot) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == id) {
|
||||
let stack = match slot {
|
||||
EquipmentSlot::MainHand => self.held().cloned(),
|
||||
EquipmentSlot::OffHand => self.offhand.clone(),
|
||||
EquipmentSlot::Head => self.armor[0].clone(),
|
||||
EquipmentSlot::Chest => self.armor[1].clone(),
|
||||
EquipmentSlot::Legs => self.armor[2].clone(),
|
||||
EquipmentSlot::Feet => self.armor[3].clone(),
|
||||
EquipmentSlot::Body | EquipmentSlot::Saddle => None,
|
||||
};
|
||||
let Some(stack) = stack else {
|
||||
return;
|
||||
};
|
||||
if let Some(p) = self.player.as_ref().map(|p| p.position) {
|
||||
self.break_sound_at(&stack, [p.x, p.y, p.z]);
|
||||
}
|
||||
self.eat_particles(&stack, 5);
|
||||
return;
|
||||
}
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id as u32) else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = m.item_by_slot(slot) else {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let (eye, pitch, yaw, silent) = (m.eye_height(), m.head_pitch, m.yaw, m.silent);
|
||||
if !silent {
|
||||
self.break_sound_at(&stack, at);
|
||||
}
|
||||
let options = self.item_particle(&stack);
|
||||
let mut spawned = Vec::new();
|
||||
particles::item_use_particles(
|
||||
&mut self.world.random,
|
||||
pitch,
|
||||
yaw,
|
||||
at,
|
||||
at[1] + eye,
|
||||
5,
|
||||
|p, v| spawned.push((p, v)),
|
||||
);
|
||||
for (p, v) in spawned {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.breakItem`'s sound: the stack's `break_sound` (none when
|
||||
/// the component is removed), heard by this client only
|
||||
/// (`playLocalSound`) at the entity's feet, at 0.8 volume and a pitch of
|
||||
/// 0.8 to 1.2.
|
||||
fn break_sound_at(&mut self, stack: &ItemStack, at: [f64; 3]) {
|
||||
let models = &self.pack.item_renderer.models;
|
||||
let sound = match models
|
||||
.components(&stack_view(stack))
|
||||
.get("minecraft:break_sound")
|
||||
{
|
||||
Some(Value::String(id)) => id.clone(),
|
||||
Some(Value::Object(inline)) => match inline.get("sound_id") {
|
||||
Some(Value::String(id)) => id.clone(),
|
||||
_ => return,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.4;
|
||||
self.audio.play_unshared(&sound, Some(at), 0.8, pitch);
|
||||
}
|
||||
/// The slime ball, snowball and cobweb models behind `item_slime`,
|
||||
/// `item_snowball` and `item_cobweb`, resolved on first use.
|
||||
pub(super) fn ensure_item_particle_sprites(&mut self) {
|
||||
|
||||
@@ -183,7 +183,8 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
| "minecraft:axolotl"
|
||||
| "minecraft:dolphin"
|
||||
| "minecraft:squid"
|
||||
| "minecraft:glow_squid" => Some(16),
|
||||
| "minecraft:glow_squid"
|
||||
| "minecraft:sulfur_cube" => Some(16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -627,10 +628,11 @@ fn mob_meta(
|
||||
}
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE.
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
|
||||
if e.size > 1 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.size)));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(1)));
|
||||
}
|
||||
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the
|
||||
// guest swells the creeper from them. The direction always goes in,
|
||||
@@ -1613,6 +1615,7 @@ impl Game<'_> {
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
streaming: Some(&self.streaming),
|
||||
};
|
||||
host.hub.movement_checks = (
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
@@ -1869,6 +1872,7 @@ impl Game<'_> {
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
streaming: Some(&self.streaming),
|
||||
};
|
||||
host.hub.place_new_player(conn, &mut ctx);
|
||||
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
|
||||
|
||||
@@ -1583,6 +1583,33 @@ impl Game<'_> {
|
||||
self.send_admin_feedback(host, Some(conn));
|
||||
}
|
||||
}
|
||||
// handleEntityTagQuery: a game master's F3+I on an entity of their
|
||||
// level gets its Entity.saveWithoutId.
|
||||
S::EntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if let Some(tag) = self.level_entity_tag(host, p.entity) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::TagQuery(play::TagQuery {
|
||||
transaction: p.transaction,
|
||||
tag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
// handleBlockEntityTagQuery: BlockEntity.saveWithoutMetadata, or
|
||||
// null where there is no block entity.
|
||||
S::BlockEntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
let tag = self
|
||||
.block_entity_tag(p.pos)
|
||||
.unwrap_or(horizon_world::nbt::Tag::End);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::TagQuery(play::TagQuery {
|
||||
transaction: p.transaction,
|
||||
tag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
S::Unknown(id, payload)
|
||||
if id == horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION =>
|
||||
{
|
||||
@@ -1638,6 +1665,31 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.getEntity(id)`'s `Entity.saveWithoutId`: the player or
|
||||
/// other entity of the level in place with that network id.
|
||||
fn level_entity_tag(&mut self, host: &mut LanHost, id: i32) -> Option<horizon_world::nbt::Tag> {
|
||||
use super::commands::Target;
|
||||
let player = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id == id && host.hub.here(g))
|
||||
.map(|g| g.conn);
|
||||
let mut tag = match player {
|
||||
Some(player) => self
|
||||
.as_guest(host, player, |g| g.entity_nbt(Target::Player))
|
||||
.flatten()?,
|
||||
None => {
|
||||
let target = self.level_entity(id)?;
|
||||
self.entity_nbt(target)?
|
||||
}
|
||||
};
|
||||
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"id".into());
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.tryPickItem` for the guest: a matching stack
|
||||
/// is selected, else a creative player gets the item; the held slot is sent
|
||||
/// back and the changed slots follow with the tick's inventory changes.
|
||||
@@ -2232,6 +2284,7 @@ impl Game<'_> {
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
streaming: Some(&self.streaming),
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
@@ -2450,7 +2503,7 @@ impl Game<'_> {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
None if at[1] < f64::from(self.world.bottom_y() - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
None => self.guest_death_message(conn, me),
|
||||
@@ -2719,9 +2772,10 @@ impl Game<'_> {
|
||||
for (moved, on_ground) in moves {
|
||||
let movement = Vec3::new(moved[0], moved[1], moved[2]);
|
||||
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
|
||||
let sprinting = w.player.sprinting;
|
||||
w.player
|
||||
.cause_food_exhaustion(if sprinting { 0.2 } else { 0.05 });
|
||||
// ServerGamePacketListenerImpl.handlePlayerPositionChange:
|
||||
// the server's copy jumps with the player, so the velocity
|
||||
// it sends them keeps the jump.
|
||||
let _ = w.jump_from_ground();
|
||||
}
|
||||
let landed = (|| -> horizon_client::Result<()> {
|
||||
w.set_on_ground_with_movement(on_ground, false, movement)?;
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
effects,
|
||||
lan::attributes::{add_modifier, generic_syncable, instance, operation_named},
|
||||
mobs::{
|
||||
Mob,
|
||||
Mob, Species,
|
||||
finalize::{self, Attr, Op},
|
||||
},
|
||||
};
|
||||
@@ -147,6 +147,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// AbstractHorse.randomizeAttributes rolls a horse's jump strength as
|
||||
// its base (kept in `vector[0]`), so the instance is synced: its
|
||||
// rider's client jumps it (`getCustomJump`).
|
||||
let rolled = matches!(
|
||||
m.species(),
|
||||
Species::Horse | Species::SkeletonHorse | Species::ZombieHorse
|
||||
);
|
||||
let jump_base = m
|
||||
.extra
|
||||
.attributes
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.bases.iter().any(|(a, _)| *a == Attr::JumpStrength));
|
||||
if rolled
|
||||
&& !jump_base
|
||||
&& m.extra.vector[0] > 0.0
|
||||
&& let Some(a) = self.synced_attribute(kind, "minecraft:jump_strength")
|
||||
{
|
||||
instance(out, a, m.extra.vector[0]).base = m.extra.vector[0];
|
||||
}
|
||||
if let (Some(a), Some((amount, Op::AddMultipliedBase))) = (
|
||||
self.synced_attribute(kind, "minecraft:movement_speed"),
|
||||
finalize::transient(m, Attr::MovementSpeed),
|
||||
|
||||
@@ -31,6 +31,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_client::mobs::equine;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
@@ -225,9 +226,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat its rider
|
||||
/// drives goes where the client says, unless it moved too quickly or
|
||||
/// wrongly; then it stays, and the client is told where it is.
|
||||
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat or the
|
||||
/// saddled horse its rider drives goes where the client says, unless it
|
||||
/// moved too quickly or wrongly; then it stays, and the client is told
|
||||
/// where it is.
|
||||
pub(super) fn guest_move_vehicle(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
@@ -264,9 +266,41 @@ impl Game<'_> {
|
||||
};
|
||||
host.hub.send_to(conn, &C::MoveVehicle(packet));
|
||||
};
|
||||
// Only a boat is driven by its rider (getControllingPassenger); a
|
||||
// minecart that is elsewhere on the client is put back.
|
||||
let Some(i) = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v)) else {
|
||||
// Only a boat, and a horse its rider's client moves, are driven by
|
||||
// their rider (getControllingPassenger); a minecart that is elsewhere
|
||||
// on the client is put back.
|
||||
let boat = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v));
|
||||
let mount = vehicle.and_then(|v| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.position(|m| m.id == v && equine::moved_by_client(m))
|
||||
});
|
||||
// The vehicle's id, position, rotation, motion, box, step height
|
||||
// and ground.
|
||||
let (id, old, yaw, velocity, bounds, step, on_ground) = if let Some(i) = boat {
|
||||
let b = &self.boats[i];
|
||||
(
|
||||
b.id,
|
||||
b.position,
|
||||
b.yaw,
|
||||
b.velocity,
|
||||
b.bounds(),
|
||||
0.0,
|
||||
b.on_ground,
|
||||
)
|
||||
} else if let Some(i) = mount {
|
||||
let m = &self.mobs[i];
|
||||
let step = m.kind.step_height as f32;
|
||||
(
|
||||
m.id,
|
||||
m.position,
|
||||
m.yaw,
|
||||
m.velocity,
|
||||
m.bounds(),
|
||||
step,
|
||||
m.on_ground,
|
||||
)
|
||||
} else {
|
||||
if let Some(c) = vehicle.and_then(|v| self.carts.iter().find(|c| c.id == v))
|
||||
&& c.position.distance_squared(Vec3::new(x, y, z)) > 1.0
|
||||
{
|
||||
@@ -274,8 +308,6 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
};
|
||||
let b = &self.boats[i];
|
||||
let old = b.position;
|
||||
let good = good.map_or(old, |g| Vec3::new(g[0], g[1], g[2]));
|
||||
let target = Vec3::new(
|
||||
x.clamp(-3.0E7, 3.0E7),
|
||||
@@ -283,16 +315,15 @@ impl Game<'_> {
|
||||
z.clamp(-3.0E7, 3.0E7),
|
||||
);
|
||||
// Moved too quickly: more than 10 blocks past its own motion.
|
||||
let moved = target.distance_squared(old) - b.velocity.length_squared();
|
||||
let moved = target.distance_squared(old) - velocity.length_squared();
|
||||
if moved > 100.0 && !owner {
|
||||
log::warn!("boat {} moved too quickly! {moved}", b.id);
|
||||
back(host, old, b.yaw);
|
||||
log::warn!("vehicle {id} moved too quickly! {moved}");
|
||||
back(host, old, yaw);
|
||||
return;
|
||||
}
|
||||
// vehicle.move(PLAYER, target - lastGood) with the boat's collisions:
|
||||
// where it can't follow the client (a quarter block off, the height
|
||||
// aside), it moved wrongly.
|
||||
let bounds = b.bounds();
|
||||
// vehicle.move(PLAYER, target - lastGood) with the vehicle's
|
||||
// collisions: where it can't follow the client (a quarter block off,
|
||||
// the height aside), it moved wrongly.
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -300,7 +331,7 @@ impl Game<'_> {
|
||||
};
|
||||
let wanted = target.subtract(good);
|
||||
let resolved = scene
|
||||
.resolve(bounds, wanted, 0.0, b.on_ground)
|
||||
.resolve(bounds, wanted, step, on_ground)
|
||||
.unwrap_or(wanted);
|
||||
let reached = old.add_vector(resolved);
|
||||
let wrongly = (target.x - reached.x).powi(2) + (target.z - reached.z).powi(2) > 0.0625;
|
||||
@@ -311,10 +342,24 @@ impl Game<'_> {
|
||||
.unobstructed(moved_box.deflate(Vec3::new(1.0E-5, 1.0E-5, 1.0E-5)))
|
||||
.unwrap_or(true);
|
||||
if free_before && (wrongly || !free_after) {
|
||||
log::warn!("boat {} moved wrongly", b.id);
|
||||
back(host, old, b.yaw);
|
||||
log::warn!("vehicle {id} moved wrongly");
|
||||
back(host, old, yaw);
|
||||
return;
|
||||
}
|
||||
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
|
||||
if let Some(i) = mount {
|
||||
// absSnapTo: where and how its rider's client turned it; the
|
||||
// rider sits on it from its own ride tick.
|
||||
let m = &mut self.mobs[i];
|
||||
m.position = target;
|
||||
m.yaw = p.yaw;
|
||||
m.head_pitch = p.pitch;
|
||||
m.on_ground = p.on_ground;
|
||||
return;
|
||||
}
|
||||
let Some(i) = boat else {
|
||||
return;
|
||||
};
|
||||
let b = &mut self.boats[i];
|
||||
b.previous = old;
|
||||
b.velocity = target.subtract(old);
|
||||
@@ -324,7 +369,6 @@ impl Game<'_> {
|
||||
b.on_ground = p.on_ground;
|
||||
let seat = self.player_boat_seat(i);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! The game's side of the Launch API (docs/api/launch.md): the options another launcher may pass,
|
||||
//! `--launch-api`'s reply, the exit codes, and docs/api/launch-reference.md, generated from them.
|
||||
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api`.
|
||||
|
||||
/// The Launch API's version (docs/api/stability.md): an addition makes the next patch.
|
||||
pub const VERSION: &str = "0.1.0";
|
||||
|
||||
/// One option of the contract: its name, its value's placeholder (none for a flag), what it does.
|
||||
pub struct LaunchOption {
|
||||
pub name: &'static str,
|
||||
pub value: Option<&'static str>,
|
||||
/// For the reference only.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub what: &'static str,
|
||||
}
|
||||
|
||||
const fn option(
|
||||
name: &'static str,
|
||||
value: Option<&'static str>,
|
||||
what: &'static str,
|
||||
) -> LaunchOption {
|
||||
LaunchOption { name, value, what }
|
||||
}
|
||||
|
||||
/// What a launcher may pass. The game takes more (`--help` lists them), but only these are
|
||||
/// promised: they keep their meaning within a line of this API.
|
||||
pub const OPTIONS: &[LaunchOption] = &[
|
||||
option(
|
||||
"--resources",
|
||||
Some("DIR"),
|
||||
"The prepared resources (horizon-prepare's output). Default: `resources` beside the executable, else `.local/horizon-client/26.3`.",
|
||||
),
|
||||
option(
|
||||
"--game-dir",
|
||||
Some("DIR"),
|
||||
"The game folder: `saves/`, `options.txt`, `resourcepacks/`, `horizon-mods/`, `screenshots/`. Created when missing. Default: the working directory. `--gameDir` is the same.",
|
||||
),
|
||||
option(
|
||||
"--home",
|
||||
Some("DIR"),
|
||||
"Logs (`logs/latest.log`) and crash reports (`crash-reports/`). Default: `HORIZON_HOME`, else `horizon` in the platform data folder.",
|
||||
),
|
||||
option(
|
||||
"--width",
|
||||
Some("N"),
|
||||
"The window's starting width (default 1280).",
|
||||
),
|
||||
option(
|
||||
"--height",
|
||||
Some("N"),
|
||||
"The window's starting height (default 720).",
|
||||
),
|
||||
option(
|
||||
"--fullscreen",
|
||||
None,
|
||||
"Opens the window fullscreen, whatever `options.txt` says; `options.txt` is not changed.",
|
||||
),
|
||||
option(
|
||||
"--session-file",
|
||||
Some("PATH"),
|
||||
"The signed-in account, as the session JSON below. Read once, then deleted.",
|
||||
),
|
||||
option(
|
||||
"--session-stdin",
|
||||
None,
|
||||
"The same JSON on standard input, so it never touches the disk.",
|
||||
),
|
||||
option(
|
||||
"--quickPlaySingleplayer",
|
||||
Some("WORLD"),
|
||||
"Opens this world of `saves/` at start, only if it exists (else the title screen). WORLD is one folder name: a path is refused.",
|
||||
),
|
||||
option(
|
||||
"--quickPlayMultiplayer",
|
||||
Some("HOST[:PORT]"),
|
||||
"Joins this server at start.",
|
||||
),
|
||||
option(
|
||||
"--launch-api",
|
||||
None,
|
||||
"Prints the reply below and exits 0, before anything else runs.",
|
||||
),
|
||||
];
|
||||
|
||||
/// How the game ends, for the launcher that started it.
|
||||
pub mod exit_code {
|
||||
/// The player quit, or `--help` and `--launch-api` printed.
|
||||
pub const OK: i32 = 0;
|
||||
/// The game stopped on an error; a crash report was saved.
|
||||
pub const ERROR: i32 = 1;
|
||||
/// The command line was refused (an unknown option, a missing or bad value); no crash report.
|
||||
pub const USAGE: i32 = 2;
|
||||
/// A panic; a crash report was saved.
|
||||
pub const PANIC: i32 = 101;
|
||||
}
|
||||
|
||||
const EXIT_CODES: &[(i32, &str)] = &[
|
||||
(
|
||||
exit_code::OK,
|
||||
"The player quit, or `--help` or `--launch-api` printed.",
|
||||
),
|
||||
(
|
||||
exit_code::ERROR,
|
||||
"The game stopped on an error. A crash report was saved.",
|
||||
),
|
||||
(
|
||||
exit_code::USAGE,
|
||||
"The command line was refused: an unknown option, a missing or bad value. No crash report.",
|
||||
),
|
||||
(
|
||||
exit_code::PANIC,
|
||||
"The game panicked. A crash report was saved.",
|
||||
),
|
||||
];
|
||||
|
||||
/// A command line the game refuses: exit code 2, no crash report.
|
||||
#[derive(Debug)]
|
||||
pub struct UsageError(pub String);
|
||||
|
||||
impl std::fmt::Display for UsageError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UsageError {}
|
||||
|
||||
/// `--launch-api`'s reply for a build of this `horizon` version.
|
||||
pub fn reply(horizon: &str) -> String {
|
||||
let options: Vec<_> = OPTIONS
|
||||
.iter()
|
||||
.map(|o| serde_json::json!({ "name": o.name, "value": o.value }))
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"launch_api": VERSION,
|
||||
"horizon": horizon,
|
||||
"minecraft": horizon_core::VANILLA_VERSION,
|
||||
"options": options,
|
||||
"exit_codes": EXIT_CODES.iter().map(|(code, _)| code).collect::<Vec<_>>(),
|
||||
"crash_report_line": horizon_client::crash::SAVED_LINE,
|
||||
}))
|
||||
.expect("a JSON value")
|
||||
}
|
||||
|
||||
/// docs/api/launch-reference.md.
|
||||
#[cfg(test)]
|
||||
pub fn markdown() -> String {
|
||||
use std::fmt::Write;
|
||||
let mut out = String::new();
|
||||
let w = &mut out;
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api -->"
|
||||
);
|
||||
let _ = writeln!(w, "# Launch API reference\n");
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"What `horizon-client` promises a launcher that starts it, Launch API {VERSION}. The guide: \
|
||||
[launch.md](launch.md); stability and versions: [stability.md](stability.md).\n"
|
||||
);
|
||||
let _ = writeln!(w, "## Options\n");
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"Any other option is refused with exit code 2, so ask `--launch-api` before passing one \
|
||||
a build may not know.\n"
|
||||
);
|
||||
let _ = writeln!(w, "| Option | What it does |\n| --- | --- |");
|
||||
for o in OPTIONS {
|
||||
let usage = match o.value {
|
||||
Some(value) => format!("`{} {value}`", o.name),
|
||||
None => format!("`{}`", o.name),
|
||||
};
|
||||
let _ = writeln!(w, "| {usage} | {} |", o.what);
|
||||
}
|
||||
let _ = writeln!(w, "\n## `--launch-api`\n");
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"Prints one JSON object on stdout and exits 0. `horizon` is the build's version; readers \
|
||||
ignore fields they don't know.\n"
|
||||
);
|
||||
let _ = writeln!(w, "```json\n{}\n```\n", reply("<version>"));
|
||||
let _ = writeln!(w, "## Exit codes\n\n| Code | Meaning |\n| --- | --- |");
|
||||
for (code, what) in EXIT_CODES {
|
||||
let _ = writeln!(w, "| {code} | {what} |");
|
||||
}
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"\nWhen a crash report is saved, the last line on stderr is `{}` followed by the \
|
||||
report's path.",
|
||||
horizon_client::crash::SAVED_LINE.trim_end()
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(args: &[&str]) -> horizon_client::Result<Option<super::super::Options>> {
|
||||
super::super::Options::parse(args.iter().map(|a| a.to_string()))
|
||||
}
|
||||
|
||||
/// Every promised option is one the game accepts.
|
||||
#[test]
|
||||
fn launch_api_options_are_accepted() {
|
||||
for o in OPTIONS {
|
||||
let args: Vec<&str> = std::iter::once(o.name)
|
||||
.chain(o.value.map(|v| if v == "N" { "100" } else { "x" }))
|
||||
.collect();
|
||||
assert!(parse(&args).is_ok(), "{} is refused", o.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_api_flags_are_read() {
|
||||
let o = parse(&["--fullscreen", "--game-dir", "g", "--session-stdin"])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(o.fullscreen && o.session_stdin);
|
||||
assert_eq!(o.game_dir.as_deref(), Some(std::path::Path::new("g")));
|
||||
assert!(parse(&["--launch-api"]).unwrap().is_none());
|
||||
assert!(parse(&["--no-such-option"]).is_err());
|
||||
}
|
||||
|
||||
/// A launcher shows the error to the player, so it names the option.
|
||||
#[test]
|
||||
fn launch_api_bad_value_names_its_option() {
|
||||
let error = |args: &[&str]| parse(args).err().unwrap().to_string();
|
||||
assert_eq!(
|
||||
error(&["--width", "wide"]),
|
||||
"--width: invalid digit found in string"
|
||||
);
|
||||
assert_eq!(error(&["--bogus", "x"]), "--bogus: unknown option");
|
||||
}
|
||||
|
||||
/// Quick play opens one folder of saves/, as on vanilla: nothing outside it.
|
||||
#[test]
|
||||
fn launch_api_quick_play_is_one_folder_of_saves() {
|
||||
for name in ["../x", "a/b", "/abs", ".", ".."] {
|
||||
assert!(
|
||||
parse(&["--quickPlaySingleplayer", name]).is_err(),
|
||||
"{name} is accepted"
|
||||
);
|
||||
}
|
||||
assert!(parse(&["--quickPlaySingleplayer", "E2E World"]).is_ok());
|
||||
}
|
||||
|
||||
/// docs/api/launch-reference.md is what `markdown` writes.
|
||||
#[test]
|
||||
fn launch_api_reference_is_current() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("docs/api/launch-reference.md");
|
||||
let want = markdown();
|
||||
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
|
||||
std::fs::write(&path, &want).unwrap();
|
||||
}
|
||||
let have = std::fs::read_to_string(&path)
|
||||
.unwrap_or_default()
|
||||
.replace("\r\n", "\n");
|
||||
assert!(
|
||||
have == want,
|
||||
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -249,10 +249,7 @@ impl Game<'_> {
|
||||
let mut root = None;
|
||||
for step in 1..11 {
|
||||
let y = pos.y + step;
|
||||
if !(horizon_client::chunk::MIN_Y
|
||||
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
|
||||
.contains(&y)
|
||||
{
|
||||
if !(self.world.bottom_y()..self.world.top_y()).contains(&y) {
|
||||
break;
|
||||
}
|
||||
let state = self.world.block(pos.x, y, pos.z);
|
||||
|
||||
@@ -112,6 +112,11 @@ pub(super) struct Levels {
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
/// A parked level, without opening or loading it.
|
||||
pub(super) fn parked_world(&self, dim: Dim) -> Option<&World> {
|
||||
self.parked.get(&dim).map(|level| &level.world)
|
||||
}
|
||||
|
||||
/// The level not in place, in `dim`.
|
||||
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
|
||||
self.parked.get_mut(&dim)
|
||||
@@ -136,6 +141,7 @@ impl Game<'_> {
|
||||
self.level.seed,
|
||||
)?;
|
||||
let mut world = World::new(self.res.facts().air, self.level.seed);
|
||||
world.shape = dim.shape();
|
||||
world.fast_lava = dim == Dim::Nether;
|
||||
Ok(Parked {
|
||||
world,
|
||||
@@ -365,8 +371,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.level_pass = Pass::default();
|
||||
if result.is_err() {
|
||||
self.native_lifecycle.cancel_server_tick();
|
||||
}
|
||||
result?;
|
||||
self.close_idle_levels()
|
||||
if let Err(error) = self.close_idle_levels() {
|
||||
self.native_lifecycle.cancel_server_tick();
|
||||
return Err(error);
|
||||
}
|
||||
// `ServerTickEnd`: an errored tick or an idle-close failure (at
|
||||
// the `?` above) never reaches this site.
|
||||
self.native_lifecycle_server_tick_end(paused);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a
|
||||
|
||||
@@ -21,6 +21,9 @@ pub(super) struct Bolt {
|
||||
converted: Vec<u32>,
|
||||
/// The mobs it has struck (`hitEntities`).
|
||||
hit: Vec<u32>,
|
||||
/// Items a cushion dropped under it, spared for 20 ticks
|
||||
/// (`Cushion.dropItem`: `setInvulnerableTime(20)`).
|
||||
spared: Vec<u32>,
|
||||
/// Blocks it set on fire (`blocksSetOnFire`).
|
||||
fires: i32,
|
||||
/// The player who channelled it (`LightningBolt.cause`): `Some(None)` for
|
||||
@@ -119,7 +122,7 @@ impl Game<'_> {
|
||||
if !section.blocks.maybe_has(&mut rod) {
|
||||
continue;
|
||||
}
|
||||
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
|
||||
let base_y = self.world.bottom_y() + 16 * i as i32;
|
||||
for j in 0..4096 {
|
||||
if !rod(section.get(j)) {
|
||||
continue;
|
||||
@@ -219,6 +222,7 @@ impl Game<'_> {
|
||||
visual_only,
|
||||
converted: Vec::new(),
|
||||
hit: Vec::new(),
|
||||
spared: Vec::new(),
|
||||
fires: 0,
|
||||
cause: None,
|
||||
});
|
||||
@@ -240,6 +244,7 @@ impl Game<'_> {
|
||||
visual_only: true,
|
||||
converted: Vec::new(),
|
||||
hit: Vec::new(),
|
||||
spared: Vec::new(),
|
||||
fires: 0,
|
||||
cause: None,
|
||||
});
|
||||
@@ -505,6 +510,17 @@ impl Game<'_> {
|
||||
if !events.is_empty() {
|
||||
let _ = self.handle_mob_events(events);
|
||||
}
|
||||
// Cushion.thunderHit: a cushion in the box breaks and drops its item
|
||||
// (the bolt is no player, so no creative spare). Last first, since
|
||||
// break_cushion removes the entry.
|
||||
for i in (0..self.cushions.len()).rev() {
|
||||
let p = self.cushions[i].position;
|
||||
if inside([p.x, p.y, p.z]) {
|
||||
let before = self.dropped.len();
|
||||
self.break_cushion(i, false);
|
||||
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
|
||||
}
|
||||
}
|
||||
// Every player in the box, the server's players too.
|
||||
let strike = move |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_ref().map(|p| p.position)
|
||||
@@ -527,6 +543,7 @@ impl Game<'_> {
|
||||
self.dropped.retain(|e| {
|
||||
!inside([e.position.x, e.position.y, e.position.z])
|
||||
|| e.stack.item.contains("netherite")
|
||||
|| (e.age < 20 && b.spared.contains(&e.id))
|
||||
});
|
||||
struck.into_iter().map(|(_, view)| view).collect()
|
||||
}
|
||||
|
||||
@@ -1074,7 +1074,7 @@ impl Game<'_> {
|
||||
for dz in 0..scale {
|
||||
let (x, z) = (min_x + dx, min_z + dz);
|
||||
let mut y = self.world_surface(x, z);
|
||||
let min_y = super::MIN_Y;
|
||||
let min_y = self.world.bottom_y();
|
||||
let mut color;
|
||||
if y <= min_y {
|
||||
color = 11;
|
||||
|
||||
@@ -398,6 +398,9 @@ impl Game<'_> {
|
||||
steer,
|
||||
sprint: keys.sprint || self.keys.contains(&winit::keyboard::KeyCode::ControlLeft),
|
||||
jumping: keys.jump,
|
||||
// A server's player (a LAN guest, single player's window) drives
|
||||
// a horse from its own client (`handleMoveVehicle`).
|
||||
client: self.lan_acting.is_some(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Game<'_> {
|
||||
/// Whether the chunk mesh doesn't show the blocks at `p` yet (edited,
|
||||
/// meshing, or waiting for its upload).
|
||||
pub(super) fn section_mesh_pending(&self, p: [i32; 3]) -> bool {
|
||||
let Some(y) = p[1].checked_sub(horizon_client::chunk::MIN_Y) else {
|
||||
let Some(y) = p[1].checked_sub(self.world.layout().min_y()) else {
|
||||
return false;
|
||||
};
|
||||
let section = (p[0] >> 4, y.div_euclid(16), p[2] >> 4);
|
||||
|
||||
@@ -77,7 +77,7 @@ impl Game<'_> {
|
||||
|| self.camera_type.first_person()
|
||||
|| self.sleeping.is_some()
|
||||
|| self.spectator()
|
||||
|| self.potions.effects.get("invisibility").is_some()
|
||||
|| self.own_invisible()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 7 adds block-change observations (`native_block_changed`).
|
||||
//!
|
||||
//! API 6 adds neighbour-change observations (`native_neighbor_changed`).
|
||||
//!
|
||||
//! API 5 adds read-only server block queries (`native_server_block`).
|
||||
//!
|
||||
//! API 4 adds authoritative server tick hooks (`native_server_tick`).
|
||||
//!
|
||||
//! API 3 adds the world lifecycle hook (`native_world_lifecycle`).
|
||||
//!
|
||||
//! API 2 adds the item panel beside container screens and its recipe pages
|
||||
@@ -21,8 +29,11 @@
|
||||
//! that would break such a bundle is not made.
|
||||
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
|
||||
//! either (`tests::hook_points_stay_hookable`).
|
||||
use super::native_lifecycle::{NativeLifecycleContext, WorldPhase};
|
||||
use super::{Game, Screen, execution_role::ExecutionRole, native_panel_recipes::RecipePage};
|
||||
use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhase};
|
||||
use super::{
|
||||
Game, GameLevel, LevelDimension, Screen, execution_role::ExecutionRole,
|
||||
native_panel_recipes::RecipePage,
|
||||
};
|
||||
use horizon_client::{
|
||||
chat::ChatSource,
|
||||
extras::Corner,
|
||||
@@ -32,13 +43,44 @@ use horizon_client::{
|
||||
mod_list::{self, Installed, NativePage},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{level::BlockId, registry::BlockStateId};
|
||||
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 3;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 7;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// An authoritative server committed a changed state through `Level.setBlock`.
|
||||
/// The world contains `new`; block side effects and neighbour updates follow.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_block_changed(
|
||||
&self,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
old: BlockStateId,
|
||||
new: BlockStateId,
|
||||
) {
|
||||
let _ = (dimension, pos, old, new);
|
||||
}
|
||||
|
||||
/// An authoritative server is about to deliver `neighborChanged`.
|
||||
/// State and block IDs are valid in this level's registries for this call.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_neighbor_changed(
|
||||
&self,
|
||||
dimension: LevelDimension,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
changed: BlockId,
|
||||
moved: bool,
|
||||
) {
|
||||
let _ = (dimension, pos, state, changed, moved);
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom a native mod asks for (`native_zoom`): the factor (1 is none),
|
||||
/// eased through the field of view like the spyglass when `smooth`, and the
|
||||
@@ -284,6 +326,16 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (context, phase, error_text);
|
||||
}
|
||||
/// The authoritative server tick's phases, read-only. A paused pass
|
||||
/// fires nothing; a frozen world carries `world_running` false except
|
||||
/// during a requested step.
|
||||
pub(super) fn native_server_tick(
|
||||
&mut self,
|
||||
context: &NativeLifecycleContext,
|
||||
phase: ServerTickPhase,
|
||||
) {
|
||||
let _ = (context, phase);
|
||||
}
|
||||
/// The HUD (not drawn under F1), after the minimap: place a box with
|
||||
/// `corners.place` (boxes stack away from their edge) and draw it with
|
||||
/// `native_fill` and `native_text`. Skip a corner `native_corner_shown`
|
||||
@@ -692,8 +744,25 @@ mod tests {
|
||||
.split("/// Helpers for native mods")
|
||||
.next()
|
||||
.unwrap();
|
||||
let level_section = source
|
||||
.split("impl GameLevel<'_> {")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("\n}")
|
||||
.next()
|
||||
.unwrap();
|
||||
let level_hooks: BTreeSet<_> = level_section
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
line.trim()
|
||||
.strip_prefix("pub(super) fn native_")?
|
||||
.split('(')
|
||||
.next()
|
||||
})
|
||||
.collect();
|
||||
let declared: BTreeSet<_> = section
|
||||
.lines()
|
||||
.chain(level_section.lines())
|
||||
.filter_map(|line| {
|
||||
line.trim()
|
||||
.strip_prefix("pub(super) fn native_")?
|
||||
@@ -711,9 +780,14 @@ mod tests {
|
||||
"invalid or duplicate hook: {name} v{version}"
|
||||
);
|
||||
names.insert(name);
|
||||
let owner = if level_hooks.contains(name) {
|
||||
"GameLevel"
|
||||
} else {
|
||||
"Game"
|
||||
};
|
||||
assert_eq!(
|
||||
hook["target"].as_str().unwrap(),
|
||||
format!("horizon_client::game::native_hooks::Game<'_>::native_{name}")
|
||||
format!("horizon_client::game::native_hooks::{owner}<'_>::native_{name}")
|
||||
);
|
||||
}
|
||||
assert_eq!(names, declared);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Read-only world lifecycle observations for native mods on authoritative
|
||||
//! servers. The deprecated in-frame path delivers no lifecycle events.
|
||||
//! Read-only lifecycle observations for native mods on authoritative servers.
|
||||
//! The deprecated in-frame path delivers no lifecycle events. A paused pass
|
||||
//! observes nothing; a frozen world reports `world_running: false` except during a step.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
@@ -22,6 +23,10 @@ pub(super) enum NativeAuthority {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct NativeWorldEpoch(u64);
|
||||
|
||||
/// Non-paused authoritative server ticks since open (not game or frame time).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct NativeTickId(u64);
|
||||
|
||||
/// Internal state for deciding which events can fire.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum OpenState {
|
||||
@@ -44,14 +49,23 @@ pub(super) enum WorldPhase {
|
||||
WorldCloseFailed,
|
||||
}
|
||||
|
||||
/// The tick phases carry whether the world runs, including frozen steps.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ServerTickPhase {
|
||||
ServerTickStart { world_running: bool },
|
||||
ServerTickEnd { world_running: bool },
|
||||
}
|
||||
|
||||
/// Context for every world lifecycle phase.
|
||||
/// `authority` is the server role. LocalAuthoritative and RemoteClient never
|
||||
/// fire today. `epoch` identifies the open attempt, including failed opens;
|
||||
/// the same epoch identifies its ready, final-close, or failed-close event.
|
||||
/// `tick` counts non-paused server ticks since the world opened.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct NativeLifecycleContext {
|
||||
pub(super) authority: NativeAuthority,
|
||||
pub(super) epoch: NativeWorldEpoch,
|
||||
pub(super) tick: NativeTickId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -59,6 +73,10 @@ struct OpenAttempt {
|
||||
authority: NativeAuthority,
|
||||
epoch: u64,
|
||||
session: OpenState,
|
||||
ticks: u64,
|
||||
/// A tick started and its end has not fired (or the tick errored).
|
||||
tick_open: bool,
|
||||
tick_world_running: bool,
|
||||
}
|
||||
|
||||
impl OpenAttempt {
|
||||
@@ -66,19 +84,30 @@ impl OpenAttempt {
|
||||
NativeLifecycleContext {
|
||||
authority: self.authority,
|
||||
epoch: NativeWorldEpoch(self.epoch),
|
||||
tick: NativeTickId(self.ticks),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lifecycle bookkeeping: the current attempt's epoch and session phase.
|
||||
/// It only decides whether a phase fires; every hook body is empty, so with no
|
||||
/// native mods nothing changes.
|
||||
/// The lifecycle bookkeeping: the current attempt's epoch, session phase
|
||||
/// and non-paused tick count. It only decides whether a phase fires; every
|
||||
/// hook body is empty, so with no native mods nothing changes.
|
||||
#[derive(Default)]
|
||||
pub(super) struct NativeLifecycle {
|
||||
attempt: Option<OpenAttempt>,
|
||||
read_scope: bool,
|
||||
}
|
||||
|
||||
impl NativeLifecycle {
|
||||
/// A read is valid only during a tick hook with its current tick context.
|
||||
pub(super) fn permits_read(&self, context: &NativeLifecycleContext) -> bool {
|
||||
self.read_scope
|
||||
&& self
|
||||
.attempt
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.session == OpenState::Ready && a.context() == *context)
|
||||
}
|
||||
|
||||
/// The open starts: a fresh process-wide epoch, never reused.
|
||||
pub(super) fn begin_open(&mut self, authority: NativeAuthority) {
|
||||
let epoch = NEXT_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -86,6 +115,9 @@ impl NativeLifecycle {
|
||||
authority,
|
||||
epoch,
|
||||
session: OpenState::Opening,
|
||||
ticks: 0,
|
||||
tick_open: false,
|
||||
tick_world_running: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,6 +168,49 @@ impl NativeLifecycle {
|
||||
self.attempt = None;
|
||||
Some(context)
|
||||
}
|
||||
|
||||
/// Non-paused ready ticks only; a paused pass observes nothing, does not count.
|
||||
pub(super) fn server_tick_start(
|
||||
&mut self,
|
||||
world_running: bool,
|
||||
) -> Option<(NativeLifecycleContext, ServerTickPhase)> {
|
||||
let attempt = self.attempt.as_mut()?;
|
||||
if attempt.session != OpenState::Ready {
|
||||
return None;
|
||||
}
|
||||
attempt.ticks += 1;
|
||||
attempt.tick_open = true;
|
||||
attempt.tick_world_running = world_running;
|
||||
Some((
|
||||
attempt.context(),
|
||||
ServerTickPhase::ServerTickStart { world_running },
|
||||
))
|
||||
}
|
||||
|
||||
/// Discard the pending end when a tick fails.
|
||||
pub(super) fn cancel_server_tick(&mut self) {
|
||||
if let Some(attempt) = &mut self.attempt {
|
||||
attempt.tick_open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairs the last start; an errored tick never reaches it.
|
||||
pub(super) fn server_tick_end(
|
||||
&mut self,
|
||||
paused: bool,
|
||||
) -> Option<(NativeLifecycleContext, ServerTickPhase)> {
|
||||
let attempt = self.attempt.as_mut()?;
|
||||
if paused || attempt.session != OpenState::Ready || !attempt.tick_open {
|
||||
return None;
|
||||
}
|
||||
attempt.tick_open = false;
|
||||
Some((
|
||||
attempt.context(),
|
||||
ServerTickPhase::ServerTickEnd {
|
||||
world_running: attempt.tick_world_running,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -185,12 +260,58 @@ impl Game<'_> {
|
||||
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFinal, "");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn native_lifecycle_server_tick_start(&mut self, world_running: bool) {
|
||||
if let Some((context, phase)) = self.native_lifecycle.server_tick_start(world_running) {
|
||||
self.native_lifecycle.read_scope = true;
|
||||
self.native_server_tick(&context, phase);
|
||||
self.native_lifecycle.read_scope = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// An errored tick returned at `result?` before this site.
|
||||
pub(super) fn native_lifecycle_server_tick_end(&mut self, paused: bool) {
|
||||
if let Some((context, phase)) = self.native_lifecycle.server_tick_end(paused) {
|
||||
self.native_lifecycle.read_scope = true;
|
||||
self.native_server_tick(&context, phase);
|
||||
self.native_lifecycle.read_scope = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frozen_steps_are_observed_without_consuming_them() {
|
||||
let mut lifecycle = opening();
|
||||
lifecycle.world_open_ready();
|
||||
let mut clock = horizon_client::clock::FixedStep::default();
|
||||
clock.set_frozen(true);
|
||||
clock.set_frozen_ticks_to_run(4);
|
||||
let mut observed = 0;
|
||||
for _ in 0..6 {
|
||||
let running = clock.world_runs();
|
||||
let (_, start) = lifecycle.server_tick_start(running).unwrap();
|
||||
let (_, end) = lifecycle.server_tick_end(false).unwrap();
|
||||
assert_eq!(
|
||||
start,
|
||||
ServerTickPhase::ServerTickStart {
|
||||
world_running: running
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
end,
|
||||
ServerTickPhase::ServerTickEnd {
|
||||
world_running: running
|
||||
}
|
||||
);
|
||||
observed += usize::from(running);
|
||||
}
|
||||
assert_eq!(observed, 4);
|
||||
}
|
||||
|
||||
/// A lifecycle mid-open on the server thread.
|
||||
fn opening() -> NativeLifecycle {
|
||||
let mut lifecycle = NativeLifecycle::default();
|
||||
@@ -198,6 +319,27 @@ mod tests {
|
||||
lifecycle
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_reads_require_the_current_hook_scope() {
|
||||
let mut life = NativeLifecycle::default();
|
||||
life.begin_open(NativeAuthority::IntegratedServer);
|
||||
let ready = life.world_open_ready().unwrap();
|
||||
assert!(!life.permits_read(&ready));
|
||||
let (tick, _) = life.server_tick_start(true).unwrap();
|
||||
life.read_scope = true;
|
||||
assert!(life.permits_read(&tick));
|
||||
assert!(!life.permits_read(&ready));
|
||||
life.read_scope = false;
|
||||
assert!(!life.permits_read(&tick));
|
||||
life.server_tick_end(false).unwrap();
|
||||
life.begin_close();
|
||||
life.world_close_final().unwrap();
|
||||
life.begin_open(NativeAuthority::IntegratedServer);
|
||||
life.world_open_ready().unwrap();
|
||||
life.read_scope = true;
|
||||
assert!(!life.permits_read(&tick));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_ready_fires_once_per_epoch() {
|
||||
let mut lifecycle = opening();
|
||||
@@ -213,6 +355,7 @@ mod tests {
|
||||
let context = lifecycle.world_open_failed().expect("the failure fires");
|
||||
let failed_epoch = context.epoch;
|
||||
assert!(lifecycle.world_open_ready().is_none(), "the epoch is gone");
|
||||
assert!(lifecycle.server_tick_start(true).is_none());
|
||||
lifecycle.begin_open(NativeAuthority::IntegratedServer);
|
||||
let context = lifecycle.world_open_ready().expect("the retry opens fresh");
|
||||
assert!(context.epoch.0 > failed_epoch.0, "a fresh epoch");
|
||||
@@ -229,6 +372,7 @@ mod tests {
|
||||
lifecycle.world_close_final().is_none(),
|
||||
"a repeated close request is a no-op"
|
||||
);
|
||||
assert!(lifecycle.server_tick_start(true).is_none());
|
||||
assert!(lifecycle.world_open_ready().is_none());
|
||||
}
|
||||
|
||||
@@ -261,8 +405,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let load = source.find("self.load_world(WorldRequest::new(").unwrap();
|
||||
let ready = source.find("self.native_lifecycle_world_ready();").unwrap();
|
||||
let tick_loop = source.find("self.tick_server_timed(paused)?;").unwrap();
|
||||
assert!(open < load && load < ready && ready < tick_loop);
|
||||
let tick = source.find("self.tick_server_timed(paused)?;").unwrap();
|
||||
assert!(open < load && load < ready && ready < tick);
|
||||
|
||||
let stop = source.split("fn stop_dedicated").nth(1).unwrap();
|
||||
let begin = stop.find("self.native_lifecycle.begin_close();").unwrap();
|
||||
@@ -272,6 +416,33 @@ mod tests {
|
||||
assert!(begin < save && save < close && close < release);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_server_ticks_cancel_before_end() {
|
||||
let tick = include_str!("levels.rs")
|
||||
.split("fn tick_server(&mut self, paused: bool)")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("fn tick_server_timed")
|
||||
.next()
|
||||
.unwrap();
|
||||
let first_cancel = tick
|
||||
.find("self.native_lifecycle.cancel_server_tick();")
|
||||
.unwrap();
|
||||
let result = tick.find("result?;").unwrap();
|
||||
let close = tick
|
||||
.find("if let Err(error) = self.close_idle_levels()")
|
||||
.unwrap();
|
||||
let second_cancel = tick
|
||||
.rfind("self.native_lifecycle.cancel_server_tick();")
|
||||
.unwrap();
|
||||
let end = tick
|
||||
.find("self.native_lifecycle_server_tick_end(paused);")
|
||||
.unwrap();
|
||||
assert!(
|
||||
first_cancel < result && result < close && close < second_cancel && second_cancel < end
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_delivery_stays_off_the_deprecated_in_frame_path() {
|
||||
let game = include_str!("../game.rs")
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Read-only block queries for a native server tick hook.
|
||||
use super::{Dim, Game, native_lifecycle::NativeLifecycleContext};
|
||||
use horizon_client::world::World;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry};
|
||||
|
||||
/// Why a server block cannot be read.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum NativeBlockReadError {
|
||||
/// The context is stale or the call is outside its server tick hook.
|
||||
InactiveContext,
|
||||
/// The dimension is unknown or is not currently open on the server.
|
||||
UnavailableDimension,
|
||||
OutOfBounds,
|
||||
Unloaded,
|
||||
InvalidState,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Read a loaded block in a server tick hook, without loading a chunk.
|
||||
/// The returned name and properties are owned and remain valid after the hook.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_server_block(
|
||||
&self,
|
||||
context: &NativeLifecycleContext,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
) -> Result<BlockState, NativeBlockReadError> {
|
||||
if !self.native_lifecycle.permits_read(context) {
|
||||
return Err(NativeBlockReadError::InactiveContext);
|
||||
}
|
||||
let dim = match dimension {
|
||||
"minecraft:overworld" => Dim::Overworld,
|
||||
"minecraft:the_nether" => Dim::Nether,
|
||||
"minecraft:the_end" => Dim::End,
|
||||
_ => return Err(NativeBlockReadError::UnavailableDimension),
|
||||
};
|
||||
let world = if dim == self.dim {
|
||||
Some(&self.world)
|
||||
} else {
|
||||
self.levels.parked_world(dim)
|
||||
}
|
||||
.ok_or(NativeBlockReadError::UnavailableDimension)?;
|
||||
read_block(world, dim, self.pack.assets.registry, pos)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an existing world state, preserving unloaded and invalid states.
|
||||
fn read_block(
|
||||
world: &World,
|
||||
dimension: Dim,
|
||||
registry: &BlockRegistry,
|
||||
[x, y, z]: [i32; 3],
|
||||
) -> Result<BlockState, NativeBlockReadError> {
|
||||
if !(-30_000_000..30_000_000).contains(&x)
|
||||
|| !(-30_000_000..30_000_000).contains(&z)
|
||||
|| !(dimension.min_y()..=dimension.max_y()).contains(&y)
|
||||
{
|
||||
return Err(NativeBlockReadError::OutOfBounds);
|
||||
}
|
||||
if !world.loaded(x, z) {
|
||||
return Err(NativeBlockReadError::Unloaded);
|
||||
}
|
||||
registry
|
||||
.state(world.block(x, y, z))
|
||||
.cloned()
|
||||
.ok_or(NativeBlockReadError::InvalidState)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn reads_only_loaded_in_bounds_states() {
|
||||
let facts = br#"{"defaults":{"minecraft:air":0,"test:pillar":1},"states":[
|
||||
["air",true,false,"","minecraft:air"],
|
||||
["pillar",false,false,"",{"id":"test:pillar","properties":{"axis":"x"}}]
|
||||
]}"#;
|
||||
let registry = BlockRegistry::from_facts_json(&facts[..]).unwrap();
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
world.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0)));
|
||||
world.set(-1, 64, 1, BlockStateId(1)).unwrap();
|
||||
let block = read_block(&world, Dim::Overworld, ®istry, [-1, 64, 1]).unwrap();
|
||||
assert_eq!(block.name, "test:pillar");
|
||||
assert_eq!(block.properties["axis"], "x");
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Overworld, ®istry, [-1, 65, 1])
|
||||
.unwrap()
|
||||
.name,
|
||||
"minecraft:air"
|
||||
);
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Overworld, ®istry, [0, 64, 1]),
|
||||
Err(NativeBlockReadError::Unloaded)
|
||||
);
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Nether, ®istry, [-1, -1, 1]),
|
||||
Err(NativeBlockReadError::OutOfBounds)
|
||||
);
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Overworld, ®istry, [30_000_000, 64, 1]),
|
||||
Err(NativeBlockReadError::OutOfBounds)
|
||||
);
|
||||
world.set(-1, 64, 1, BlockStateId(99)).unwrap();
|
||||
assert_eq!(
|
||||
read_block(&world, Dim::Overworld, ®istry, [-1, 64, 1]),
|
||||
Err(NativeBlockReadError::InvalidState)
|
||||
);
|
||||
assert_eq!(world.chunks.len(), 1, "reads must not load chunks");
|
||||
}
|
||||
}
|
||||
@@ -234,10 +234,10 @@ impl SpawnLevel for GameLevel<'_, '_> {
|
||||
}
|
||||
impl NaturalLevel for GameLevel<'_, '_> {
|
||||
fn min_y(&self) -> i32 {
|
||||
horizon_client::chunk::MIN_Y
|
||||
self.w.world.bottom_y()
|
||||
}
|
||||
fn max_y(&self) -> i32 {
|
||||
horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1
|
||||
self.w.world.top_y() - 1
|
||||
}
|
||||
fn ambient_light(&self) -> f32 {
|
||||
match self.game.dim {
|
||||
@@ -356,7 +356,7 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
spawn_selection::heightmap_blocks(self, kind, x, y, z)
|
||||
})
|
||||
})
|
||||
.map_or(horizon_client::chunk::MIN_Y, |y| y + 1)
|
||||
.map_or(self.w.world.bottom_y(), |y| y + 1)
|
||||
}
|
||||
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
|
||||
self.w.data.state(self.state(x, y, z)).flags & data::LAND != 0
|
||||
|
||||
@@ -339,7 +339,7 @@ impl Game<'_> {
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let base = horizon_client::chunk::MIN_Y + 16 * s as i32;
|
||||
let base = self.world.bottom_y() + 16 * s as i32;
|
||||
for y in base..base + 16 {
|
||||
for lz in 0..16 {
|
||||
for lx in 0..16 {
|
||||
@@ -869,21 +869,18 @@ impl Game<'_> {
|
||||
reach: horizon_core::geometry::Aabb,
|
||||
sections: &mut horizon_client::portal_sections::PortalSections,
|
||||
) -> bool {
|
||||
use horizon_client::{
|
||||
chunk::{MIN_Y, SECTIONS},
|
||||
section_mask::SectionMask,
|
||||
};
|
||||
use horizon_client::section_mask::SectionMask;
|
||||
let (min, max) = (reach.min_position(), reach.max_position());
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return true;
|
||||
}
|
||||
let (min, max) = (BlockPos::containing(min), BlockPos::containing(max));
|
||||
let top = SECTIONS as i32 - 1;
|
||||
let top = self.world.layout().sections() as i32 - 1;
|
||||
let (x0, x1) = (min.x >> 4, max.x >> 4);
|
||||
let (z0, z1) = (min.z >> 4, max.z >> 4);
|
||||
let (y0, y1) = (
|
||||
((min.y - MIN_Y) >> 4).clamp(0, top),
|
||||
((max.y - MIN_Y) >> 4).clamp(0, top),
|
||||
((min.y - self.world.bottom_y()) >> 4).clamp(0, top),
|
||||
((max.y - self.world.bottom_y()) >> 4).clamp(0, top),
|
||||
);
|
||||
let count = (i64::from(x1) - i64::from(x0) + 1)
|
||||
* (i64::from(y1) - i64::from(y0) + 1)
|
||||
|
||||
@@ -220,6 +220,12 @@ impl Game<'_> {
|
||||
Event::SliderDrag => {
|
||||
self.apply_settings();
|
||||
}
|
||||
// Options.createSoundSliderOptionInstance: the preview plays
|
||||
// only without a level (on the title screen's options).
|
||||
Event::Volume(source, volume) if self.title.is_some() => {
|
||||
self.audio.preview(source, volume);
|
||||
}
|
||||
Event::Volume(..) => {}
|
||||
Event::CopyAgentDetails => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
let text = self.agent_details();
|
||||
|
||||
@@ -136,45 +136,94 @@ impl Game<'_> {
|
||||
/// models and sprites (every section is meshed again,
|
||||
/// `LevelRenderer.allChanged`), the shared atlas (GUI, item, entity,
|
||||
/// particle and font textures), item models, sounds, languages, GUI
|
||||
/// metadata and the cloud and world border textures.
|
||||
/// metadata and the cloud and world border textures. The loading runs on
|
||||
/// a worker under the progress overlay (`LoadingOverlay` over
|
||||
/// `ReloadableResourceManager.createReload`'s background executor), so
|
||||
/// the window keeps drawing; `drive_pack_reload` applies it.
|
||||
pub(super) fn reload_packs(&mut self) {
|
||||
// Pack reload updates tail-owned mob metadata. Keep menus responsive
|
||||
// until those resources exist, then apply the latest selection once.
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
// A reload already under its overlay finishes first (vanilla's
|
||||
// `pendingReload`, which `runTick` starts once the overlay is gone).
|
||||
if reload_waits(
|
||||
self.rest_loader.is_some() || self.world_loader.is_some(),
|
||||
self.pack_reload.is_some(),
|
||||
) {
|
||||
self.pending_pack_reload = true;
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.reload_selected_packs() {
|
||||
log::warn!("Horizon: resource reload failed: {e}");
|
||||
if self.screen != super::Screen::Options {
|
||||
self.open_options_at(
|
||||
self.title.is_some(),
|
||||
horizon_client::options_ui::Page::Packs,
|
||||
);
|
||||
}
|
||||
self.ui.options.resource_error = Some(e.to_string());
|
||||
if let Err(e) = self.spawn_pack_reload(ReloadKind::Selection) {
|
||||
self.selection_reload_failed(&format!("could not start the pack reload: {e}"));
|
||||
}
|
||||
}
|
||||
/// The CPU half of the reload, safe on a worker (`PackReload` runs it):
|
||||
/// `apply_staged_pack` finishes the GPU half and the swap.
|
||||
pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result<StagedPack> {
|
||||
/// `reloadResourcePacks`' failure: the pack screen again, with the error.
|
||||
fn selection_reload_failed(&mut self, error: &str) {
|
||||
log::warn!("Horizon: resource reload failed: {error}");
|
||||
if self.screen != super::Screen::Options {
|
||||
self.open_options_at(
|
||||
self.title.is_some(),
|
||||
horizon_client::options_ui::Page::Packs,
|
||||
);
|
||||
}
|
||||
self.ui.options.resource_error = Some(error.to_string());
|
||||
}
|
||||
/// Stages the selection with the server's active packs on top on a
|
||||
/// worker (`createReload`'s background executor); the frame draws its
|
||||
/// progress meanwhile, and the overlay takes no input (`LoadingOverlay`),
|
||||
/// so what is held is released (`KeyMapping.releaseAll`).
|
||||
pub(super) fn spawn_pack_reload(&mut self, kind: ReloadKind) -> std::io::Result<()> {
|
||||
self.keys.clear();
|
||||
self.physical_keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
stage_pack(
|
||||
&vanilla,
|
||||
&self.settings.resource_packs,
|
||||
&self.server_packs.active,
|
||||
&self.settings.mods.enabled,
|
||||
&self.options.dev_mods,
|
||||
&self.pack.assets,
|
||||
&self.res,
|
||||
&self.pack.context,
|
||||
self.settings.force_unicode,
|
||||
self.settings.japanese_glyphs,
|
||||
&super::Progress::new(),
|
||||
)
|
||||
let (selected, server, mods, dev_mods) = (
|
||||
self.settings.resource_packs.clone(),
|
||||
self.server_packs.active.clone(),
|
||||
self.settings.mods.enabled.clone(),
|
||||
self.options.dev_mods.clone(),
|
||||
);
|
||||
let (assets, res, context) = (
|
||||
self.pack.assets.clone(),
|
||||
self.res.clone(),
|
||||
self.pack.context.clone(),
|
||||
);
|
||||
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
|
||||
self.pack_reload = Some(PackReload::spawn(kind, move |progress| {
|
||||
stage_pack(
|
||||
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, unicode,
|
||||
japanese, progress,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
})?);
|
||||
Ok(())
|
||||
}
|
||||
/// `LoadingOverlay.tick` once the reload is done: the staged pack is
|
||||
/// applied on the render thread, and its outcome goes to whoever asked
|
||||
/// (`reloadResourcePacks`' callback, or `PackReloadConfig.Callbacks`).
|
||||
pub(super) fn drive_pack_reload(&mut self) {
|
||||
let Some((kind, staged)) = self.pack_reload.as_mut().and_then(PackReload::finished) else {
|
||||
return;
|
||||
};
|
||||
self.pack_reload = None;
|
||||
let result =
|
||||
staged.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()));
|
||||
match kind {
|
||||
ReloadKind::Selection => {
|
||||
if let Err(e) = result {
|
||||
self.selection_reload_failed(&e);
|
||||
}
|
||||
}
|
||||
ReloadKind::Server => {
|
||||
if let Err(e) = &result {
|
||||
log::warn!("Horizon: server resource pack reload failed: {e}");
|
||||
}
|
||||
self.finish_pack_reload(result.is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The render-thread half: the renderer on the new pack's textures, then
|
||||
/// the swap (`ReloadInstance`'s application listeners).
|
||||
@@ -257,16 +306,10 @@ impl Game<'_> {
|
||||
self.creative_screen.forget_items();
|
||||
Ok(())
|
||||
}
|
||||
/// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then
|
||||
/// applied (`PackReload` runs the same two phases across frames).
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
let staged = self.stage_selected_packs()?;
|
||||
self.apply_staged_pack(staged)
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a
|
||||
/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay
|
||||
/// The CPU half of a pack reload (`PackReload` runs it on a worker;
|
||||
/// `apply_staged_pack` does the GPU half and the swap): the overlay
|
||||
/// root, the models and sprites, the menu bundle, the pack's tables and its
|
||||
/// CPU mip chains.
|
||||
pub(super) fn stage_pack(
|
||||
@@ -320,3 +363,127 @@ pub(super) struct StagedPack {
|
||||
pub pack: PackResources,
|
||||
pub textures: PreparedTextures,
|
||||
}
|
||||
|
||||
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
|
||||
const RELOAD_LINES: [&str; 4] = [
|
||||
"Preparing resources",
|
||||
"Loading assets",
|
||||
"Loading pack tables",
|
||||
"Applying resources",
|
||||
];
|
||||
/// Whether a reload asked for now waits: for the world's resources, or for
|
||||
/// the reload already under its overlay (`Minecraft.reloadResourcePacks`
|
||||
/// hands back `pendingReload` while a `LoadingOverlay` is up).
|
||||
fn reload_waits(world_loading: bool, in_flight: bool) -> bool {
|
||||
world_loading || in_flight
|
||||
}
|
||||
/// Who asked for a pack reload: its outcome goes back there.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ReloadKind {
|
||||
/// The player's own selection (`Minecraft.reloadResourcePacks`).
|
||||
Selection,
|
||||
/// A server's packs (`DownloadedPackSource.startReload`).
|
||||
Server,
|
||||
}
|
||||
type StageResult = std::result::Result<StagedPack, String>;
|
||||
/// A pack reload whose CPU half runs on a worker while `progress` feeds the
|
||||
/// loading overlay; `Game::drive_pack_reload` applies it on the render thread.
|
||||
pub(super) struct PackReload {
|
||||
pub(super) progress: super::Progress,
|
||||
kind: ReloadKind,
|
||||
handle: Option<std::thread::JoinHandle<StageResult>>,
|
||||
}
|
||||
impl PackReload {
|
||||
/// Starts `stage` on its own thread and returns at once.
|
||||
fn spawn(
|
||||
kind: ReloadKind,
|
||||
stage: impl FnOnce(&super::Progress) -> StageResult + Send + 'static,
|
||||
) -> std::io::Result<Self> {
|
||||
let progress = super::Progress::with(&RELOAD_LINES);
|
||||
let worker = progress.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("pack-reload".into())
|
||||
.spawn(move || stage(&worker))?;
|
||||
Ok(Self {
|
||||
progress,
|
||||
kind,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
pub(super) fn kind(&self) -> ReloadKind {
|
||||
self.kind
|
||||
}
|
||||
/// The staged pack once the worker is done (taken once); none while it runs.
|
||||
fn finished(&mut self) -> Option<(ReloadKind, StageResult)> {
|
||||
if !self.handle.as_ref()?.is_finished() {
|
||||
return None;
|
||||
}
|
||||
let staged = self
|
||||
.handle
|
||||
.take()?
|
||||
.join()
|
||||
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
|
||||
Some((self.kind, staged))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{sync::mpsc, time::Duration};
|
||||
|
||||
fn wait(reload: &mut PackReload) -> (ReloadKind, StageResult) {
|
||||
for _ in 0..500 {
|
||||
if let Some(done) = reload.finished() {
|
||||
return done;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
panic!("the reload worker never finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reload_stages_on_a_worker_while_the_caller_goes_on() {
|
||||
// The stage blocks until the caller lets it go: `spawn` returning at
|
||||
// all means the window thread is free to draw the overlay.
|
||||
let (release, blocked) = mpsc::channel::<()>();
|
||||
let (thread, ran_on) = mpsc::channel();
|
||||
let mut reload = PackReload::spawn(ReloadKind::Selection, move |progress| {
|
||||
blocked.recv().unwrap();
|
||||
progress.set(2);
|
||||
thread
|
||||
.send(std::thread::current().name().map(str::to_owned))
|
||||
.unwrap();
|
||||
Err("staged".into())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(reload.finished().is_none(), "still staging");
|
||||
assert_eq!(reload.progress.line(), "Preparing resources");
|
||||
release.send(()).unwrap();
|
||||
let (kind, staged) = wait(&mut reload);
|
||||
assert_eq!(kind, ReloadKind::Selection);
|
||||
assert_eq!(staged.err().as_deref(), Some("staged"));
|
||||
assert_eq!(reload.progress.line(), "Loading pack tables");
|
||||
assert_eq!(ran_on.recv().unwrap().as_deref(), Some("pack-reload"));
|
||||
assert!(reload.finished().is_none(), "the outcome is taken once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_stage_fails_the_reload() {
|
||||
let mut reload = PackReload::spawn(ReloadKind::Server, |_| panic!("bad pack")).unwrap();
|
||||
let (kind, staged) = wait(&mut reload);
|
||||
assert_eq!(kind, ReloadKind::Server);
|
||||
assert_eq!(
|
||||
staged.err().as_deref(),
|
||||
Some("the pack reload worker panicked")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reload_waits_for_world_resources_and_the_one_in_flight() {
|
||||
assert!(!reload_waits(false, false));
|
||||
assert!(reload_waits(true, false));
|
||||
assert!(reload_waits(false, true));
|
||||
assert!(reload_waits(true, true));
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,8 @@ impl Game<'_> {
|
||||
| "minecraft:arrow"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:small_fireball"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:breeze_wind_charge"
|
||||
| "minecraft:falling_block"
|
||||
| "minecraft:end_crystal"
|
||||
| "minecraft:ender_dragon"
|
||||
@@ -294,7 +296,10 @@ impl Game<'_> {
|
||||
self.add_projectile(p);
|
||||
}
|
||||
}
|
||||
"minecraft:fireball" | "minecraft:small_fireball" => {
|
||||
"minecraft:fireball"
|
||||
| "minecraft:small_fireball"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:breeze_wind_charge" => {
|
||||
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
|
||||
self.add_projectile(p);
|
||||
}
|
||||
|
||||
@@ -123,15 +123,18 @@ impl CameraType {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Entity.isInvisible` for the window's player: shared flag 5 as its server
|
||||
/// sets it (`shared_flag`), with the Invisibility effect or by a plugin's
|
||||
/// `setInvisible` alone, as pose plugins hide the real body under the pose
|
||||
/// (#3788); a world in the frame has only the effect.
|
||||
fn own_invisible(effects: &horizon_client::effects::PlayerEffects, shared_flag: bool) -> bool {
|
||||
shared_flag || effects.get("invisibility").is_some()
|
||||
}
|
||||
/// `LivingEntityRenderer.isBodyVisible` and `isInvisibleTo` for the player's
|
||||
/// own body, seen by itself: the Invisibility effect (`Entity.isInvisible`)
|
||||
/// hides it unless its team sees friendly invisibles, where vanilla draws it
|
||||
/// own body, seen by itself: an invisible player (`own_invisible`) hides it
|
||||
/// unless its team sees friendly invisibles, where vanilla draws it
|
||||
/// translucent.
|
||||
fn body_visible(
|
||||
effects: &horizon_client::effects::PlayerEffects,
|
||||
team: Option<&horizon_client::scoreboard::Team>,
|
||||
) -> bool {
|
||||
let invisible = effects.get("invisibility").is_some();
|
||||
fn body_visible(invisible: bool, team: Option<&horizon_client::scoreboard::Team>) -> bool {
|
||||
!horizon_client::scoreboard::invisible_to(team, team, false, invisible)
|
||||
}
|
||||
/// The player's model type (`PlayerModel` with the default skin).
|
||||
@@ -213,6 +216,60 @@ impl Game<'_> {
|
||||
/// front of) it, pulled in where visual shapes are in the way. Returns the
|
||||
/// position, yaw and pitch to render from.
|
||||
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
|
||||
// Minecraft.cameraEntity: a server's spectator camera on a mob or
|
||||
// another player.
|
||||
let (entity, descending) = match self.camera_mob() {
|
||||
Some(m) => (super::remote_camera::mob_camera_entity(m), false),
|
||||
None => (
|
||||
self.player_camera_entity()?,
|
||||
self.player.as_ref()?.shift_key_down,
|
||||
),
|
||||
};
|
||||
let mut view = self.view;
|
||||
let kind = match self.camera_type {
|
||||
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
|
||||
CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront,
|
||||
};
|
||||
let context = CollisionContext::for_entity(
|
||||
CollisionEntity {
|
||||
identity: EntityIdentity(0),
|
||||
y: entity.position[1],
|
||||
descending,
|
||||
main_hand: Some(CollisionItem::EMPTY),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let mut rays = VisualRays {
|
||||
scene: SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
},
|
||||
facts: self.res.facts(),
|
||||
context,
|
||||
};
|
||||
view.align_with_entity(&entity, partial, kind, |from, to| {
|
||||
let hit = clip_world(
|
||||
&mut rays,
|
||||
Vec3::new(from[0], from[1], from[2]),
|
||||
Vec3::new(to[0], to[1], to[2]),
|
||||
RayBlockMode::Visual,
|
||||
RayFluidMode::None,
|
||||
4096,
|
||||
)
|
||||
.ok()?;
|
||||
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
|
||||
});
|
||||
self.view = view;
|
||||
Some((
|
||||
glam::DVec3::from_array(view.position),
|
||||
view.y_rot,
|
||||
view.x_rot,
|
||||
))
|
||||
}
|
||||
/// The player as `Camera.alignWithEntity` sees it.
|
||||
fn player_camera_entity(&mut self) -> Option<CameraEntity> {
|
||||
let (scale, distance) = {
|
||||
let p = self.player.as_mut()?;
|
||||
(
|
||||
@@ -282,48 +339,7 @@ impl Game<'_> {
|
||||
}),
|
||||
mount,
|
||||
};
|
||||
let mut view = self.view;
|
||||
let kind = match self.camera_type {
|
||||
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
|
||||
CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront,
|
||||
};
|
||||
let context = CollisionContext::for_entity(
|
||||
CollisionEntity {
|
||||
identity: EntityIdentity(0),
|
||||
y: p.position.y,
|
||||
descending: p.shift_key_down,
|
||||
main_hand: Some(CollisionItem::EMPTY),
|
||||
},
|
||||
false,
|
||||
);
|
||||
let mut rays = VisualRays {
|
||||
scene: SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
},
|
||||
facts: self.res.facts(),
|
||||
context,
|
||||
};
|
||||
view.align_with_entity(&entity, partial, kind, |from, to| {
|
||||
let hit = clip_world(
|
||||
&mut rays,
|
||||
Vec3::new(from[0], from[1], from[2]),
|
||||
Vec3::new(to[0], to[1], to[2]),
|
||||
RayBlockMode::Visual,
|
||||
RayFluidMode::None,
|
||||
4096,
|
||||
)
|
||||
.ok()?;
|
||||
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
|
||||
});
|
||||
self.view = view;
|
||||
Some((
|
||||
glam::DVec3::from_array(view.position),
|
||||
view.y_rot,
|
||||
view.x_rot,
|
||||
))
|
||||
Some(entity)
|
||||
}
|
||||
/// Keep the player's model in step with the player (`LivingEntity.tick`
|
||||
/// body turning, `WalkAnimationState`, the swing and the pose).
|
||||
@@ -523,9 +539,16 @@ impl Game<'_> {
|
||||
None => self.player_team(),
|
||||
}
|
||||
}
|
||||
/// `Entity.isInvisible` for the player itself (`own_invisible`).
|
||||
pub(super) fn own_invisible(&self) -> bool {
|
||||
own_invisible(
|
||||
&self.potions.effects,
|
||||
self.remote.as_ref().is_some_and(|r| r.own_invisible),
|
||||
)
|
||||
}
|
||||
/// Whether the player's own body is drawn (`body_visible`).
|
||||
pub(super) fn own_body_visible(&self) -> bool {
|
||||
body_visible(&self.potions.effects, self.own_team())
|
||||
body_visible(self.own_invisible(), self.own_team())
|
||||
}
|
||||
/// The player's model for the entity pass in third person.
|
||||
pub(super) fn player_model_geometry(
|
||||
@@ -567,7 +590,7 @@ impl Game<'_> {
|
||||
);
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
// CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks).
|
||||
if self.potions.effects.get("invisibility").is_none() {
|
||||
if !self.own_invisible() {
|
||||
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
@@ -658,7 +681,7 @@ impl Game<'_> {
|
||||
&mut verts,
|
||||
);
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
if self.potions.effects.get("invisibility").is_none() {
|
||||
if !self.own_invisible() {
|
||||
self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
}
|
||||
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
@@ -1115,15 +1138,32 @@ mod preview_tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut effects = horizon_client::effects::PlayerEffects::default();
|
||||
assert!(body_visible(&effects, None));
|
||||
assert!(body_visible(own_invisible(&effects, false), None));
|
||||
effects.add(EffectInstance::new(id, 3600, 0));
|
||||
assert!(!body_visible(&effects, None), "no team: the body is hidden");
|
||||
let invisible = own_invisible(&effects, false);
|
||||
assert!(
|
||||
!body_visible(invisible, None),
|
||||
"no team: the body is hidden"
|
||||
);
|
||||
// Entity.isInvisibleTo: a team that sees friendly invisibles sees it.
|
||||
let mut board = Scoreboard::default();
|
||||
let team = board.add_team("blue");
|
||||
assert!(body_visible(&effects, Some(team)));
|
||||
assert!(body_visible(invisible, Some(team)));
|
||||
team.see_friendly_invisibles = false;
|
||||
assert!(!body_visible(&effects, Some(team)));
|
||||
assert!(!body_visible(invisible, Some(team)));
|
||||
}
|
||||
|
||||
/// #3788: a pose plugin's `Player.setInvisible(true)` sets only the
|
||||
/// shared flag (no effect); `Entity.isInvisible` reads that flag, so the
|
||||
/// real body under the pose is not drawn.
|
||||
#[test]
|
||||
fn the_servers_invisible_flag_hides_the_own_body_without_the_effect() {
|
||||
let effects = horizon_client::effects::PlayerEffects::default();
|
||||
assert!(own_invisible(&effects, true));
|
||||
assert!(!body_visible(own_invisible(&effects, true), None));
|
||||
let mut board = horizon_client::scoreboard::Scoreboard::default();
|
||||
let team = board.add_team("blue");
|
||||
assert!(body_visible(own_invisible(&effects, true), Some(team)));
|
||||
}
|
||||
|
||||
/// LivingEntityRenderer.submit: an invisible body is not drawn, its
|
||||
|
||||
@@ -226,10 +226,10 @@ impl Game<'_> {
|
||||
/// highest non-air one.
|
||||
pub(super) fn surface_height(&self, x: i32, z: i32) -> i32 {
|
||||
let top = if self.dim == Dim::Nether { 127 } else { 319 };
|
||||
(horizon_client::chunk::MIN_Y..=top)
|
||||
(self.world.bottom_y()..=top)
|
||||
.rev()
|
||||
.find(|&y| !self.res.facts().is_air(self.world.block(x, y, z)))
|
||||
.map_or(horizon_client::chunk::MIN_Y, |y| y + 1)
|
||||
.map_or(self.world.bottom_y(), |y| y + 1)
|
||||
}
|
||||
/// Every claimed village point of interest (job sites, beds, bells).
|
||||
fn village_pois(&self) -> Vec<[i32; 3]> {
|
||||
|
||||
@@ -122,6 +122,9 @@ pub(super) struct Remote {
|
||||
pub(super) logged_in: bool,
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
/// `Minecraft.cameraEntity` when the server put the view on another
|
||||
/// entity (ClientboundSetCameraPacket); None while it is the player.
|
||||
pub(super) camera: Option<i32>,
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
pub(super) use_item: Option<Arc<str>>,
|
||||
pub(super) dimension: DimensionType,
|
||||
@@ -166,6 +169,9 @@ pub(super) struct Remote {
|
||||
/// The happy ghast the player just got off, until the server's teleport
|
||||
/// puts the player on top of it (`place_ghast_under`).
|
||||
pub(super) ghast_dismount: Option<u32>,
|
||||
/// Whether the player is the first passenger of its vehicle, the one
|
||||
/// `getControllingPassenger` names.
|
||||
pub(super) first_passenger: bool,
|
||||
/// Item entities waiting for their item data: where they are and their
|
||||
/// motion.
|
||||
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
|
||||
@@ -183,6 +189,9 @@ pub(super) struct Remote {
|
||||
chat_previous: Option<Instant>,
|
||||
/// The server's command tree (ClientboundCommandsPacket).
|
||||
pub commands: Option<horizon_client::command::Dispatcher>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions this server sent (ClientboundCustomChatCompletionsPacket).
|
||||
custom_completions: std::collections::BTreeSet<String>,
|
||||
players: HashMap<u128, String>,
|
||||
/// `ClientPacketListener.playerInfoMap` as player-list entries, and
|
||||
/// which of them are listed.
|
||||
@@ -206,6 +215,9 @@ pub(super) struct Remote {
|
||||
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
|
||||
/// theirs in `Mob::glowing`.
|
||||
glowing: std::collections::HashSet<i32>,
|
||||
/// The player's own shared flag 5 (`Entity.isInvisible`) as the server
|
||||
/// last set it.
|
||||
pub(super) own_invisible: bool,
|
||||
/// The local player's profile UUID (always in the level).
|
||||
profile: u128,
|
||||
/// The local player's profile name: its score holder and team member.
|
||||
@@ -219,6 +231,9 @@ pub(super) struct Remote {
|
||||
/// inventory): like the click queue, they wait for the tick's
|
||||
/// `handleKeybinds` phase rather than going out between window events.
|
||||
pub(super) pending_keys: Vec<KeyCode>,
|
||||
/// Whether the main and off hand still wait for the server's answer to
|
||||
/// a SWAP_ITEM_WITH_OFFHAND (`remote_hand_slot_set`).
|
||||
swap_answer: [bool; 2],
|
||||
stop_mining: bool,
|
||||
placing: Option<([i32; 3], u64)>,
|
||||
/// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one.
|
||||
@@ -250,6 +265,9 @@ pub(super) struct Remote {
|
||||
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
|
||||
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
|
||||
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
|
||||
/// `ClientPacketListener.getDebugQueryHandler`: the F3+I query waiting
|
||||
/// for the server's data.
|
||||
pub(super) debug_query: super::debug_keys::DebugQuery,
|
||||
}
|
||||
|
||||
/// The time a frame spends on chunk packets before it leaves the rest of
|
||||
@@ -483,6 +501,7 @@ impl Game<'_> {
|
||||
logged_in: false,
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
camera: None,
|
||||
use_item: None,
|
||||
dimension: DimensionType::vanilla("minecraft:overworld"),
|
||||
chunk_format: Arc::new(ChunkFormat::new(
|
||||
@@ -510,6 +529,7 @@ impl Game<'_> {
|
||||
stepped: HashMap::new(),
|
||||
head_lerps: HashMap::new(),
|
||||
ghast_dismount: None,
|
||||
first_passenger: false,
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
flash_on_set_health: false,
|
||||
@@ -519,6 +539,7 @@ impl Game<'_> {
|
||||
chat_queue: VecDeque::new(),
|
||||
chat_previous: None,
|
||||
commands: None,
|
||||
custom_completions: Default::default(),
|
||||
players: HashMap::new(),
|
||||
tab_players: HashMap::new(),
|
||||
skin_textures: HashMap::new(),
|
||||
@@ -529,6 +550,7 @@ impl Game<'_> {
|
||||
level_player_names: HashMap::new(),
|
||||
level_player_textures: HashMap::new(),
|
||||
glowing: Default::default(),
|
||||
own_invisible: false,
|
||||
profile: 0,
|
||||
profile_name: String::new(),
|
||||
destroying: None,
|
||||
@@ -536,6 +558,7 @@ impl Game<'_> {
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
pending_keys: Vec::new(),
|
||||
swap_answer: [false; 2],
|
||||
stop_mining: false,
|
||||
placing: None,
|
||||
flat: false,
|
||||
@@ -552,6 +575,7 @@ impl Game<'_> {
|
||||
backlog: VecDeque::new(),
|
||||
backlog_since: None,
|
||||
server_links: Vec::new(),
|
||||
debug_query: Default::default(),
|
||||
transfer: None,
|
||||
});
|
||||
// The level's TickRateManager follows the server's ticking packets.
|
||||
@@ -909,6 +933,14 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions` of the server
|
||||
/// connection (none without one).
|
||||
pub(super) fn remote_custom_completions(&self) -> std::collections::BTreeSet<String> {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.map(|r| r.custom_completions.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The names in the server's player list, sorted.
|
||||
pub(super) fn remote_online_names(&self) -> Option<Vec<String>> {
|
||||
let r = self.remote.as_ref()?;
|
||||
@@ -1050,14 +1082,18 @@ impl Game<'_> {
|
||||
}
|
||||
Event::Reconfigured(registries) => {
|
||||
// handleConfigurationStart → clearClientLevel, and the next
|
||||
// ClientPacketListener starts with no players or scoreboard.
|
||||
// ClientPacketListener starts with no players or scoreboard,
|
||||
// and its new ClientSuggestionProvider with no custom
|
||||
// chat completions.
|
||||
self.hud_disconnected();
|
||||
self.score.board = Default::default();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.players.clear();
|
||||
r.tab_players.clear();
|
||||
r.listed.clear();
|
||||
r.custom_completions.clear();
|
||||
}
|
||||
self.ui.registries.custom_completions.clear();
|
||||
self.set_registries(registries);
|
||||
}
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
@@ -1206,6 +1242,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let dimension = r.registries.dimension(spawn.dimension_type);
|
||||
let shape = super::Dim::from_id(&dimension.name).shape();
|
||||
let same = r.dimension.name == dimension.name && keep_world;
|
||||
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
|
||||
r.dimension = dimension;
|
||||
@@ -1243,6 +1280,7 @@ impl Game<'_> {
|
||||
let count = old.len();
|
||||
horizon_client::chunk::free_columns(old, count);
|
||||
self.world = World::new(self.res.facts().air, spawn.seed);
|
||||
self.world.shape = shape;
|
||||
self.world.game_time = time;
|
||||
self.world.day_time = day;
|
||||
self.world.weather = weather;
|
||||
@@ -1390,15 +1428,6 @@ impl Game<'_> {
|
||||
self.command_block_data_arrived(key);
|
||||
}
|
||||
}
|
||||
// `handleBlockEntityData`: a command block's data arriving while its
|
||||
// edit screen is open refreshes the screen.
|
||||
if self
|
||||
.command_block_screen
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.pos == key)
|
||||
{
|
||||
self.update_command_block_screen();
|
||||
}
|
||||
}
|
||||
/// `handleAnimate`: a player wakes up, or an entity gets the critical
|
||||
/// hit (`CRIT`) or magic critical hit (`ENCHANTED_HIT`) emitter.
|
||||
@@ -1621,6 +1650,7 @@ impl Game<'_> {
|
||||
// copy in the mob list draws nothing: given the move, it kept the
|
||||
// dragon where it spawned (#2083).
|
||||
let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id);
|
||||
let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id);
|
||||
let mob = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
@@ -1644,6 +1674,13 @@ impl Game<'_> {
|
||||
if let Some(t) = r.entities.get_mut(&id) {
|
||||
t.position = position;
|
||||
}
|
||||
if driven {
|
||||
// handleMoveEntity, handleEntityPositionSync: the mount this
|
||||
// client moves (isLocalInstanceAuthoritative) keeps its own
|
||||
// position, and no steps pile up for after the ride.
|
||||
r.stepped.remove(&id);
|
||||
return;
|
||||
}
|
||||
if let Some(m) = mob {
|
||||
// A living entity: LivingEntity.createInterpolationHandler's
|
||||
// SteppedInterpolationHandler, over its type's update interval.
|
||||
@@ -1936,7 +1973,14 @@ impl Game<'_> {
|
||||
if internal == 200 {
|
||||
return; // the result slot is recomputed from the grid
|
||||
}
|
||||
*self.slot_mut(internal) = item;
|
||||
let slot = self.slot_mut(internal);
|
||||
let changed = !super::hand::matches_for_swap(slot.as_ref(), item.as_ref());
|
||||
*slot = item;
|
||||
if internal < 36 {
|
||||
self.remote_hand_slot_set(internal, changed);
|
||||
} else if internal == 504 {
|
||||
self.remote_hand_slot_set(super::shield::OFFHAND_SLOT, changed);
|
||||
}
|
||||
if internal >= super::stations::INPUT && self.station.is_some() {
|
||||
self.remote_station_slot_changed(internal);
|
||||
}
|
||||
@@ -1962,9 +2006,27 @@ impl Game<'_> {
|
||||
)
|
||||
};
|
||||
let (max_health, scale) = (value("minecraft:max_health"), value("minecraft:scale"));
|
||||
let (speed, jump) = (
|
||||
value("minecraft:movement_speed"),
|
||||
value("minecraft:jump_strength"),
|
||||
);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
|
||||
return;
|
||||
};
|
||||
// A horse its rider's client moves goes at its own speed and jumps
|
||||
// its own height (`getSpeed`, `getCustomJump`).
|
||||
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
|
||||
if let Some(speed) = speed {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MovementSpeed,
|
||||
speed,
|
||||
);
|
||||
}
|
||||
if let Some(jump) = jump {
|
||||
m.extra.vector[0] = jump;
|
||||
}
|
||||
}
|
||||
if let Some(max_health) = max_health {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
@@ -1995,8 +2057,11 @@ impl Game<'_> {
|
||||
self.potions.effects.clear();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.carried = Some(0);
|
||||
r.own_invisible = false;
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
// handleLogin: the new LocalPlayer is the camera.
|
||||
r.camera = None;
|
||||
r.logged_in = true;
|
||||
r.view_distance = p.chunk_radius;
|
||||
r.show_death_screen = p.show_death_screen;
|
||||
@@ -2022,6 +2087,11 @@ impl Game<'_> {
|
||||
// passengers. A dimension change keeps the vehicle it travels in.
|
||||
if p.keep & 2 == 0 {
|
||||
self.riding = None;
|
||||
// KEEP_ENTITY_DATA copies the old player's synched data
|
||||
// (its shared flags) to the new one; without it they reset.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.own_invisible = false;
|
||||
}
|
||||
}
|
||||
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
@@ -2033,6 +2103,8 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = None;
|
||||
r.health = (20.0, 20, 5.0);
|
||||
// handleRespawn: the new LocalPlayer is the camera.
|
||||
r.camera = None;
|
||||
}
|
||||
self.capture(true);
|
||||
}
|
||||
@@ -2052,6 +2124,7 @@ impl Game<'_> {
|
||||
self.snap_player_model_pitch();
|
||||
}
|
||||
C::PlayerLookAt(p) => self.remote_look_at(&p),
|
||||
C::SetCamera(p) => self.remote_set_camera(p.camera_id),
|
||||
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
|
||||
C::LightUpdate(p) => self.remote_light_update(&p),
|
||||
C::ForgetLevelChunk(p) => {
|
||||
@@ -2270,6 +2343,11 @@ impl Game<'_> {
|
||||
p.on_ground,
|
||||
false,
|
||||
);
|
||||
// handleTeleportEntity: the mount this client moves is put
|
||||
// there at once.
|
||||
if self.driven_mount().is_some_and(|m| m.id as i32 == p.id) {
|
||||
self.remote_move_vehicle(position, p.yaw);
|
||||
}
|
||||
}
|
||||
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
|
||||
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
|
||||
@@ -2416,10 +2494,16 @@ impl Game<'_> {
|
||||
// server stops the sprint after a hit's extra
|
||||
// knockback (the flag only, as assignValues sets
|
||||
// it: the speed modifier and wasSprinting stay).
|
||||
// Its invisible bit (Entity.isInvisible): the
|
||||
// Invisibility effect's, or a plugin's that hides
|
||||
// the real body under a pose (#3788).
|
||||
(0, Value::Byte(flags)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.sprinting = flags & 0x08 != 0;
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.own_invisible = flags & 0x20 != 0;
|
||||
}
|
||||
}
|
||||
// Entity.DATA_AIR_SUPPLY_ID and
|
||||
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
|
||||
@@ -2670,6 +2754,12 @@ impl Game<'_> {
|
||||
{
|
||||
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 47 to 52, 65 and 68: an
|
||||
// equipped item broke (onEquippedItemBroken).
|
||||
if let Some(slot) = horizon_client::mobs::item_break::equipment_break_slot(p.event)
|
||||
{
|
||||
self.remote_equipment_break(p.id, slot);
|
||||
}
|
||||
// EntityEvent.DEATH: start the death animation.
|
||||
if p.event == 3
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
@@ -2988,6 +3078,15 @@ impl Game<'_> {
|
||||
let stack = self.to_stack(&p.item);
|
||||
self.tutorial_get_item(stack.as_ref());
|
||||
let before = self.creative_stacks();
|
||||
let held = match p.slot {
|
||||
0..=35 => self.inventory[p.slot as usize].as_ref(),
|
||||
40 => self.offhand.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
let changed = !super::hand::matches_for_swap(held, stack.as_ref());
|
||||
if let Ok(slot) = usize::try_from(p.slot) {
|
||||
self.remote_hand_slot_set(slot, changed);
|
||||
}
|
||||
match p.slot {
|
||||
0..=35 => self.inventory[p.slot as usize] = stack,
|
||||
36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack,
|
||||
@@ -3048,11 +3147,23 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
C::TagQuery(p) => self.answer_tag_query(p.transaction, p.tag),
|
||||
C::Unknown(id, data)
|
||||
if id == horizon_protocol::ids::play::clientbound::COMMAND_SUGGESTIONS =>
|
||||
{
|
||||
self.handle_command_suggestions(&data);
|
||||
}
|
||||
C::CustomChatCompletions(p) => {
|
||||
// ClientPacketListener.handleCustomChatCompletions.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
horizon_client::command::modify_custom_completions(
|
||||
&mut r.custom_completions,
|
||||
horizon_client::command::CustomCompletionAction::from_id(p.action),
|
||||
&p.entries,
|
||||
);
|
||||
self.ui.registries.custom_completions = r.custom_completions.clone();
|
||||
}
|
||||
}
|
||||
C::Commands(p) => {
|
||||
let tree = horizon_client::command::network::from_packet(
|
||||
&p,
|
||||
@@ -4164,6 +4275,14 @@ impl Game<'_> {
|
||||
| u8::from(keys.jump) << 4
|
||||
| u8::from(keys.shift) << 5
|
||||
| u8::from(keys.sprint) << 6;
|
||||
// LocalPlayer.applyInput and aiStep: watching through another
|
||||
// entity, the player does not move itself; its keys still reach the
|
||||
// server, whose sneak check lets go of the camera.
|
||||
let keys = if self.controls_camera() {
|
||||
keys
|
||||
} else {
|
||||
Keys::default()
|
||||
};
|
||||
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
|
||||
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
|
||||
// The server sends no passenger's position: they sit where their
|
||||
@@ -4551,6 +4670,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// The mount this client moves ticks in `remote_ride` instead, as its
|
||||
// authoritative side (LivingEntity.tick with travelRidden).
|
||||
let driven = self.driven_mount().map(|m| m.id);
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -4562,7 +4684,7 @@ impl Game<'_> {
|
||||
let mut mob_fx = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
let player = players.contains_key(&(m.id as i32));
|
||||
if !running && !player {
|
||||
if (!running && !player) || driven == Some(m.id) {
|
||||
continue;
|
||||
}
|
||||
m.previous = m.position;
|
||||
@@ -4859,6 +4981,7 @@ impl Game<'_> {
|
||||
);
|
||||
let (yaw, pitch, selected) = (self.yaw, self.pitch, self.selected);
|
||||
let riding = self.riding.is_some();
|
||||
let controls_camera = self.controls_camera();
|
||||
let vehicle = self.remote_vehicle_packets();
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
@@ -4891,9 +5014,12 @@ impl Game<'_> {
|
||||
flags: u8::from(on_ground) | u8::from(collision) << 1,
|
||||
}));
|
||||
out.extend(vehicle);
|
||||
} else if let Some(packet) =
|
||||
r.position_sender
|
||||
.update(pos, (yaw, pitch), on_ground, collision)
|
||||
} else if controls_camera
|
||||
// LocalPlayer.sendPosition: no move while the view is another
|
||||
// entity's (the server keeps the player on it).
|
||||
&& let Some(packet) =
|
||||
r.position_sender
|
||||
.update(pos, (yaw, pitch), on_ground, collision)
|
||||
{
|
||||
out.push(packet);
|
||||
}
|
||||
@@ -5043,6 +5169,9 @@ impl Game<'_> {
|
||||
sequence: seq,
|
||||
}));
|
||||
self.tutorial_destroy(state, 0.0);
|
||||
// startDestroyBlock: BlockState.attack on the client, so a redstone
|
||||
// ore's dust (RedStoneOreBlock.attack) comes before the break.
|
||||
self.redstone_ore_particles(hit.pos);
|
||||
if progress >= 1.0 {
|
||||
// startDestroyBlock: instant survival breaks have no destroy delay.
|
||||
if !debug_stick {
|
||||
@@ -5296,6 +5425,24 @@ impl Game<'_> {
|
||||
(result, changes)
|
||||
};
|
||||
let reacts = result.consumes_action();
|
||||
// useItemOn on this client, unless a sneak with something in hand
|
||||
// skips it: RedStoneOreBlock's dust, and the wick smoke of a lit
|
||||
// candle an empty hand snuffs (CandleBlock.useItemOn).
|
||||
if hand == Hand::Main && !spectator {
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
|
||||
if !(sneaking && hands_full) {
|
||||
self.redstone_ore_particles(hit.pos);
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let lit_candle = self.block_name(hit.pos).ends_with("candle")
|
||||
&& self.pack.assets.registry.state(state).is_some_and(|s| {
|
||||
s.properties.get("lit").map(String::as_str) == Some("true")
|
||||
});
|
||||
if held.is_none() && self.may_build() && lit_candle {
|
||||
self.candle_smoke(hit.pos, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ArmorStandItem.useOn runs in startPrediction before the packet
|
||||
// goes out (the server may spawn the stand at once).
|
||||
let stand_fits = held
|
||||
@@ -5334,6 +5481,10 @@ impl Game<'_> {
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
// LeverBlock.useWithoutItem on the client: the predicted pull puffs.
|
||||
if &*self.block_name(hit.pos) == "minecraft:lever" {
|
||||
self.lever_puff(hit.pos);
|
||||
}
|
||||
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
|
||||
// (a chest, barrel, furnace, …). CONSUME does not swing.
|
||||
@@ -6108,6 +6259,11 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if self.spectator() {
|
||||
// Minecraft.startAttack: a spectator's click on an entity asks
|
||||
// to watch through it (MultiPlayerGameMode.spectate); a click on
|
||||
// nothing still tells the server (spectatorNoAction).
|
||||
let target = self.spectator_click_target();
|
||||
self.send(S::SpectatorAction(play::SpectatorAction { target }));
|
||||
return true;
|
||||
}
|
||||
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
|
||||
@@ -6258,6 +6414,7 @@ impl Game<'_> {
|
||||
self.recipe_ui.reset_screen();
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.refresh_creative_tabs();
|
||||
self.creative_screen.reopen();
|
||||
self.refresh_saved_hotbars();
|
||||
} else {
|
||||
@@ -6324,6 +6481,35 @@ impl Game<'_> {
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
// Equal stacks swap with no slot update to wait for.
|
||||
let (main, off) = (self.stack_in(self.selected), self.offhand.as_ref());
|
||||
let moves = !super::hand::matches_for_swap(main, off);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.swap_answer = [moves; 2];
|
||||
}
|
||||
}
|
||||
/// The server set inventory `slot` (`shield::OFFHAND_SLOT` for the off
|
||||
/// hand), `changed` from the stack this client held there. The stack
|
||||
/// that answers a swap (`setItemInHand`) is another object, which the
|
||||
/// hand shows after the swap animation (`hand_stack_replaced`); other
|
||||
/// updates of a hand's slot count as the held stack changing in place,
|
||||
/// as this client doesn't predict every use the way vanilla's does
|
||||
/// (a thrown snowball's stack shrinks only when the server says so).
|
||||
fn remote_hand_slot_set(&mut self, slot: usize, changed: bool) {
|
||||
let hand = if slot == super::shield::OFFHAND_SLOT {
|
||||
1
|
||||
} else if slot == self.selected {
|
||||
0
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let answer = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.is_some_and(|r| std::mem::take(&mut r.swap_answer[hand]));
|
||||
if answer && changed {
|
||||
self.hand_stack_replaced(slot);
|
||||
}
|
||||
}
|
||||
/// A container click: the server answers with the authoritative slots.
|
||||
pub(super) fn remote_click(&mut self, right: bool) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! A server's spectator camera (`ClientPacketListener.handleSetCamera`,
|
||||
//! `Minecraft.cameraEntity`): `/spectate` or a spectator's click (sent as
|
||||
//! `MultiPlayerGameMode.spectate`) attaches the view to another entity. The
|
||||
//! view follows that entity while the player stands still and sends no moves
|
||||
//! (`LocalPlayer.isControlledCamera`); the server keeps its player on the
|
||||
//! camera and lets go when the player sneaks.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
camera::{CameraEntity, LivingCamera},
|
||||
mobs::Mob,
|
||||
};
|
||||
|
||||
/// `Minecraft.setCameraEntity` from a ClientboundSetCameraPacket: the player's
|
||||
/// own id puts the view back on the player; an id the level does not hold
|
||||
/// changes nothing (`ClientboundSetCameraPacket.getEntity` is null).
|
||||
pub(super) fn camera_after_packet(
|
||||
current: Option<i32>,
|
||||
player_id: i32,
|
||||
camera_id: i32,
|
||||
in_level: bool,
|
||||
) -> Option<i32> {
|
||||
if camera_id == player_id {
|
||||
None
|
||||
} else if in_level {
|
||||
Some(camera_id)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
/// `Camera.alignWithEntity`'s view of a mob or another player: its
|
||||
/// interpolated position, `getViewYRot`/`getViewXRot` and eye height, with
|
||||
/// its scale and camera distance for a detached view. A living entity's
|
||||
/// `getViewYRot` is its head's yaw (`yHeadRot`), not its body's `yRot`.
|
||||
pub(super) fn mob_camera_entity(m: &Mob) -> CameraEntity {
|
||||
let attribute =
|
||||
|name: &str, default: f64| m.kind.attributes.get(name).copied().unwrap_or(default);
|
||||
CameraEntity {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
previous: [m.previous.x, m.previous.y, m.previous.z],
|
||||
x_rot: m.head_pitch,
|
||||
y_rot: m.head_yaw,
|
||||
x_rot_o: m.previous_head_pitch,
|
||||
y_rot_o: m.previous_head_yaw,
|
||||
eye_height: m.eye_height() as f32,
|
||||
living: Some(LivingCamera {
|
||||
scale: attribute("minecraft:scale", 1.0) as f32,
|
||||
camera_distance: attribute("minecraft:camera_distance", 4.0),
|
||||
sleeping: None,
|
||||
dead: None,
|
||||
}),
|
||||
mount: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ClientPacketListener.handleSetCamera`.
|
||||
pub(super) fn remote_set_camera(&mut self, camera_id: i32) {
|
||||
let in_level = self.mobs.iter().any(|m| m.id == camera_id as u32);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.camera = camera_after_packet(r.camera, r.entity_id, camera_id, in_level);
|
||||
}
|
||||
}
|
||||
/// The entity a server attached the view to, when it is not the player.
|
||||
pub(super) fn remote_camera_id(&self) -> Option<u32> {
|
||||
self.remote.as_ref()?.camera.map(|id| id as u32)
|
||||
}
|
||||
/// `LocalPlayer.isControlledCamera`: the view is the player's own.
|
||||
pub(super) fn controls_camera(&self) -> bool {
|
||||
self.remote_camera_id().is_none()
|
||||
}
|
||||
/// The mob the view follows, while the level still holds it.
|
||||
pub(super) fn camera_mob(&self) -> Option<&Mob> {
|
||||
let id = self.remote_camera_id()?;
|
||||
self.mobs.iter().find(|m| m.id == id)
|
||||
}
|
||||
/// The entity under a spectator's crosshair (`Minecraft.hitResult` as an
|
||||
/// `EntityHitResult`): the nearest mob or object before any block.
|
||||
pub(super) fn spectator_click_target(&self) -> Option<i32> {
|
||||
let mob = self.mob_hit().map(|(i, d)| (self.mobs[i].id as i32, d));
|
||||
let object = self.remote_object_hit(false).map(|h| (h.id, h.distance));
|
||||
[mob, object]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(_, d)| self.before_block(*d))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(id, _)| id)
|
||||
}
|
||||
/// `LevelRenderer`: the camera entity is not drawn from its own eyes.
|
||||
pub(super) fn hides_as_camera(&self, id: u32) -> bool {
|
||||
self.camera_type.first_person() && self.remote_camera_id() == Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::camera_after_packet;
|
||||
|
||||
/// The camera goes to a known entity, back to the player on its own id,
|
||||
/// and stays where it is for an entity the level does not hold.
|
||||
#[test]
|
||||
fn set_camera_follows_vanilla() {
|
||||
assert_eq!(camera_after_packet(None, 5, 9, true), Some(9));
|
||||
assert_eq!(camera_after_packet(Some(9), 5, 5, false), None);
|
||||
assert_eq!(camera_after_packet(Some(9), 5, 12, false), Some(9));
|
||||
assert_eq!(camera_after_packet(None, 5, 12, false), None);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
m.invisible = invisible;
|
||||
}
|
||||
}
|
||||
// Entity.DATA_SILENT: Entity.isSilent, which keeps the sounds
|
||||
// a client plays itself quiet (LivingEntity.breakItem's).
|
||||
(4, Value::Bool(silent)) => m.silent = *silent,
|
||||
// Entity.DATA_POSE: the pose the species animates by.
|
||||
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
|
||||
@@ -148,7 +151,7 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
m.extra.color = (*c & 15) as u8;
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions).
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
(Species::Slime | Species::MagmaCube | Species::SulfurCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
|
||||
@@ -462,6 +465,10 @@ pub(super) fn client_defaults(m: &mut Mob) {
|
||||
// Bat's constructor sets resting only on the server; the data default is 0.
|
||||
bat::set_resting(m, false);
|
||||
}
|
||||
// A horse's jump strength until the server syncs its own: the type's.
|
||||
if equine::moved_by_rider(m.species()) {
|
||||
m.extra.vector[0] = m.kind.jump_strength;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -494,6 +501,23 @@ mod tests {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// #2920: a server's horse is moved by its rider's client, which jumps
|
||||
/// it by the type's jump strength (`createBaseHorseAttributes`,
|
||||
/// `createBaseChestedHorseAttributes`) until the server syncs its own.
|
||||
#[test]
|
||||
fn a_servers_horse_jumps_its_types_strength() {
|
||||
for (kind, strength) in [("horse", 0.7), ("donkey", 0.5), ("mule", 0.5)] {
|
||||
let Some(m) = mob(&format!("minecraft:{kind}")) else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
(m.extra.vector[0] - strength).abs() < 1e-6,
|
||||
"{kind}: {}",
|
||||
m.extra.vector[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_charge_follows_target_updates_and_caps_at_its_duration() {
|
||||
use horizon_client::mobs::guardian::{beam, client_tick};
|
||||
@@ -568,6 +592,21 @@ mod tests {
|
||||
assert!(!calf.is_baby());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_mobs_receive_size_from_metadata() {
|
||||
for kind in ["slime", "magma_cube", "sulfur_cube"] {
|
||||
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(m.extra.size, 1);
|
||||
apply(&mut m, &[(18, s::INT, Value::Int(2))]);
|
||||
assert_eq!(m.extra.size, 2);
|
||||
if kind == "sulfur_cube" {
|
||||
assert!(!m.is_baby());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bat_flags_select_continuous_client_clocks_without_changing_motion() {
|
||||
use horizon_client::mobs::{MobTypes, anim, keyframes::Animation};
|
||||
|
||||
@@ -121,7 +121,7 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
|
||||
if p.stuck {
|
||||
return;
|
||||
}
|
||||
let water = w.in_water(p.position);
|
||||
let water = w.in_water(p.bounds());
|
||||
if water {
|
||||
p.velocity = p.velocity.scale(0.6);
|
||||
}
|
||||
@@ -714,7 +714,7 @@ mod tests {
|
||||
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
None
|
||||
}
|
||||
fn in_water(&self, _: Vec3) -> bool {
|
||||
fn in_water(&self, _: Aabb) -> bool {
|
||||
false
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)] {
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
//! (`ClientPacketListener.handleSetEntityPassengersPacket`). A boat is
|
||||
//! controlled by its rider's client: it ticks here and its moves go to the
|
||||
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
|
||||
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
|
||||
//! the server with the rider's input, and the rider sits in it.
|
||||
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). So is a saddled horse,
|
||||
//! donkey, mule or camel: its rider's client moves it and reports the moves,
|
||||
//! and tells the server of a riding jump (`START_RIDING_JUMP`). A minecart
|
||||
//! moves on the server with the rider's input, and the rider sits in it.
|
||||
use super::{Game, Keys, ray_box};
|
||||
use horizon_client::mobs::{Mob, equine};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
@@ -123,6 +126,11 @@ impl Game<'_> {
|
||||
display.vehicle = None;
|
||||
}
|
||||
}
|
||||
if passengers.contains(&me)
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.first_passenger = passengers.first() == Some(&me);
|
||||
}
|
||||
if passengers.contains(&me) {
|
||||
if self.riding != Some(vehicle_id) {
|
||||
self.riding = Some(vehicle_id);
|
||||
@@ -134,6 +142,9 @@ impl Game<'_> {
|
||||
} else if self.riding == Some(vehicle_id) {
|
||||
// The server takes the player out, then teleports it.
|
||||
self.riding = None;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) {
|
||||
m.rider = None;
|
||||
}
|
||||
// HappyGhast.removePassenger: a still timeout of 10 (the server's
|
||||
// STAYS_STILL confirms it), and the rider lands on top of the ghast
|
||||
// (`place_ghast_under`).
|
||||
@@ -155,10 +166,13 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
// An animal moves on the server with the rider's input
|
||||
// (PlayerInput); here the jump bar charges for the HUD and the
|
||||
// rider sits where `positionRider` puts it.
|
||||
self.charge_mount_jump(i, keys);
|
||||
// LocalPlayer.aiStep: the jump bar charges while jump is held.
|
||||
let jump = self.charge_mount_jump(i, keys);
|
||||
if self.drives(&self.mobs[i]) {
|
||||
self.drive_mount(i, keys, jump);
|
||||
}
|
||||
// Another animal moves on the server with the rider's input
|
||||
// (PlayerInput); the rider sits where `positionRider` puts it.
|
||||
self.position_rider();
|
||||
return true;
|
||||
}
|
||||
@@ -200,6 +214,9 @@ impl Game<'_> {
|
||||
/// `LocalPlayer.tick` for a passenger: the boat it controls goes to the
|
||||
/// server every tick with its paddles (the rider's own position does not).
|
||||
pub(super) fn remote_vehicle_packets(&self) -> Vec<S> {
|
||||
if let Some(m) = self.driven_mount() {
|
||||
return vec![mount_move(m)];
|
||||
}
|
||||
let Some(b) = self
|
||||
.riding
|
||||
.and_then(|id| self.boats.iter().find(|b| b.id == id))
|
||||
@@ -220,20 +237,168 @@ impl Game<'_> {
|
||||
]
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleMoveVehicle`: the server put the boat the
|
||||
/// player drives back.
|
||||
/// `ClientPacketListener.handleMoveVehicle`: the server put the boat or
|
||||
/// the mount the player drives back.
|
||||
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(id) = self.driven_mount().map(|m| m.id)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.previous = at;
|
||||
m.position = at;
|
||||
m.yaw = yaw;
|
||||
m.previous_yaw = yaw;
|
||||
m.velocity = Vec3::ZERO;
|
||||
return;
|
||||
}
|
||||
let Some(b) = self
|
||||
.riding
|
||||
.and_then(|id| self.boats.iter_mut().find(|b| b.id == id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
b.previous = at;
|
||||
b.position = at;
|
||||
b.yaw = yaw;
|
||||
b.previous_yaw = yaw;
|
||||
b.velocity = Vec3::ZERO;
|
||||
}
|
||||
|
||||
/// Whether this client moves `m`, the mount the player rides: as its
|
||||
/// first passenger (`AbstractHorse.getControllingPassenger`), on its
|
||||
/// saddle (`isLocalInstanceAuthoritative`).
|
||||
fn drives(&self, m: &Mob) -> bool {
|
||||
self.riding == Some(m.id)
|
||||
&& client_moves(m, self.remote.as_ref().is_some_and(|r| r.first_passenger))
|
||||
}
|
||||
|
||||
/// The mount this client moves, if the player rides one.
|
||||
pub(super) fn driven_mount(&self) -> Option<&Mob> {
|
||||
let id = self.riding?;
|
||||
self.mobs.iter().find(|m| m.id == id && self.drives(m))
|
||||
}
|
||||
|
||||
/// `LocalPlayer.aiStep` and `LivingEntity.travelRidden` on the
|
||||
/// controlling client: a released jump goes to the server
|
||||
/// (`sendRidingJump`), and the mount takes the rider's input and moves
|
||||
/// here (`tickRidden`, then `travel`).
|
||||
fn drive_mount(&mut self, i: usize, keys: Keys, jump: f32) {
|
||||
if jump > 0.0
|
||||
&& let Some(id) = self.remote.as_ref().map(|r| r.entity_id)
|
||||
{
|
||||
self.send(riding_jump(id, self.mount.jump_scale));
|
||||
}
|
||||
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
|
||||
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
|
||||
let m = &mut self.mobs[i];
|
||||
// playerJumpPendingScale waits for the ground.
|
||||
let pending = m.rider.map_or(0.0, |r| r.jump);
|
||||
m.rider = Some(equine::Rider {
|
||||
strafe,
|
||||
forward,
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
jump: if jump > 0.0 { jump } else { pending },
|
||||
steer: false,
|
||||
sprint: keys.sprint,
|
||||
jumping: keys.jump,
|
||||
client: false,
|
||||
});
|
||||
let mut mob = self.mobs.remove(i);
|
||||
let mut random = self.world.random.clone();
|
||||
let mut events = Vec::new();
|
||||
{
|
||||
let world = self.mob_world_base(&[]);
|
||||
mob.tick(&world, &mut random, &mut events);
|
||||
}
|
||||
// Only the server's packets take the mount away.
|
||||
mob.removed = false;
|
||||
self.world.random = random;
|
||||
self.mobs.insert(i, mob);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a player's client moves `m`, the mount it rides
|
||||
/// (`isLocalInstanceAuthoritative`): a saddled horse, donkey, mule or camel
|
||||
/// it is the first passenger of (`AbstractHorse.getControllingPassenger`).
|
||||
fn client_moves(m: &Mob, first_passenger: bool) -> bool {
|
||||
first_passenger && equine::moved_by_rider(m.species()) && m.extra.saddled && m.alive()
|
||||
}
|
||||
|
||||
/// `LocalPlayer.sendRidingJump`: START_RIDING_JUMP with
|
||||
/// `floor(getJumpRidingScale() * 100)`, as `onPlayerJump` gets it.
|
||||
fn riding_jump(player: i32, scale: f32) -> S {
|
||||
S::PlayerCommand(play::PlayerCommand {
|
||||
id: player,
|
||||
action: 3,
|
||||
data: (scale * 100.0).floor() as i32,
|
||||
})
|
||||
}
|
||||
|
||||
/// `ServerboundMoveVehiclePacket.fromEntity`: where the mount this client
|
||||
/// moves is, and how it is turned.
|
||||
fn mount_move(m: &Mob) -> S {
|
||||
S::MoveVehicle(play::MoveVehicleRequest {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
yaw: m.yaw,
|
||||
pitch: m.head_pitch,
|
||||
on_ground: m.on_ground,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::mobs::MobTypes;
|
||||
|
||||
/// A mob of the prepared resources' type `kind` (None without them).
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = MobTypes::load(&root).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
Some(Mob::new(7, t, Vec3::new(1.5, 64.0, -2.5), 0.0))
|
||||
}
|
||||
|
||||
/// #2920: on a vanilla server the rider's client moves a saddled horse,
|
||||
/// donkey, mule or camel; a pig, a llama, an unsaddled horse or a second
|
||||
/// passenger's ride is the server's.
|
||||
#[test]
|
||||
fn the_rider_moves_a_saddled_horse_on_its_client() {
|
||||
for kind in ["horse", "donkey", "mule", "camel", "pig", "llama"] {
|
||||
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
|
||||
return;
|
||||
};
|
||||
assert!(!client_moves(&m, true), "{kind} without a saddle");
|
||||
m.set_saddle(Some(horizon_client::items::ItemStack::new(
|
||||
"minecraft:saddle",
|
||||
1,
|
||||
)));
|
||||
let horse = matches!(kind, "horse" | "donkey" | "mule" | "camel");
|
||||
assert_eq!(client_moves(&m, true), horse, "{kind}");
|
||||
assert!(!client_moves(&m, false), "{kind} behind another rider");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2920: the client reports where it moved the mount, and a released
|
||||
/// jump with the bar's percentage (`sendRidingJump`).
|
||||
#[test]
|
||||
fn the_rider_sends_its_mounts_moves_and_jumps() {
|
||||
let Some(mut m) = mob("minecraft:horse") else {
|
||||
return;
|
||||
};
|
||||
(m.yaw, m.head_pitch, m.on_ground) = (90.0, 10.0, true);
|
||||
match mount_move(&m) {
|
||||
S::MoveVehicle(p) => {
|
||||
assert_eq!(p.position, [1.5, 64.0, -2.5]);
|
||||
assert_eq!((p.yaw, p.pitch, p.on_ground), (90.0, 10.0, true));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match riding_jump(42, 0.75) {
|
||||
S::PlayerCommand(p) => assert_eq!((p.id, p.action, p.data), (42, 3, 75)),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use horizon_client::{
|
||||
animate_tick::{AnimateContext, animate_tick, campfire_particles, marker_particle_target},
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, Fluid},
|
||||
chunk::{HEIGHT, MIN_Y},
|
||||
containers::BlockEntity,
|
||||
particles::{ParticleLevel, ParticleOptions, ParticleType},
|
||||
world::World,
|
||||
@@ -37,7 +36,7 @@ pub(super) fn motion_blocking_height(
|
||||
/// that is above the world's bottom and within 10 blocks of the camera.
|
||||
fn splash_height(world: &World, properties: &Properties, x: i32, y: i32, z: i32) -> Option<i32> {
|
||||
let top = motion_blocking_height(world, properties, x, z);
|
||||
(top > MIN_Y && (y - 10..=y + 10).contains(&top)).then_some(top)
|
||||
(top > world.bottom_y() && (y - 10..=y + 10).contains(&top)).then_some(top)
|
||||
}
|
||||
/// The first block at or below `from` that blocks motion (leaves only when
|
||||
/// `leaves`, as MOTION_BLOCKING_NO_LEAVES skips them) or holds a fluid, plus one.
|
||||
@@ -49,8 +48,8 @@ fn motion_blocking_leaves(
|
||||
from: i32,
|
||||
leaves: bool,
|
||||
) -> i32 {
|
||||
let mut y = from.min(MIN_Y + HEIGHT - 1);
|
||||
while y > MIN_Y {
|
||||
let mut y = from.min(world.top_y() - 1);
|
||||
while y > world.bottom_y() {
|
||||
let f = facts.get(world.block(x, y, z));
|
||||
// #minecraft:leaves block motion, but MOTION_BLOCKING_NO_LEAVES skips them
|
||||
// (their water still counts).
|
||||
@@ -64,7 +63,7 @@ fn motion_blocking_leaves(
|
||||
}
|
||||
y -= 1;
|
||||
}
|
||||
MIN_Y
|
||||
world.bottom_y()
|
||||
}
|
||||
/// `Biome.getPrecipitationAt` is rain (not snow) at this position.
|
||||
fn rains_at(world: &World, biomes: &Biomes, x: i32, y: i32, z: i32) -> bool {
|
||||
@@ -363,6 +362,9 @@ impl Game<'_> {
|
||||
// The day timeline: firefly bush sounds from 12600 to 23400.
|
||||
let firefly_bush_sounds = self.dim == Dim::Overworld
|
||||
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
|
||||
// EnvironmentAttributes.CREAKING_ACTIVE: the same overworld night.
|
||||
let creaking_active = self.dim == Dim::Overworld
|
||||
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
|
||||
let tables = &self.pack.context.tables;
|
||||
let model_offset = |state, x, z| tables.offset(state, x, z);
|
||||
let gateway_particles = |state, p| {
|
||||
@@ -382,6 +384,7 @@ impl Game<'_> {
|
||||
raining_at: &raining_at,
|
||||
height_no_leaves: &height_no_leaves,
|
||||
firefly_bush_sounds,
|
||||
creaking_active,
|
||||
sky_darken: self.sky_darken(),
|
||||
marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)),
|
||||
model_offset: &model_offset,
|
||||
|
||||
@@ -54,6 +54,7 @@ impl Game<'_> {
|
||||
"last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
|
||||
"commands": w.commands,
|
||||
"version": w.version,
|
||||
"corrupted": w.corrupted,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -411,13 +411,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn sculk_scan_chunk(&mut self, key: (i32, i32)) {
|
||||
use horizon_client::chunk::MIN_Y;
|
||||
let Some(chunk) = self.world.chunks.get(&key).cloned() else {
|
||||
return;
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
for (s, section) in chunk.sections.iter().enumerate() {
|
||||
let y0 = MIN_Y + s as i32 * 16;
|
||||
let y0 = self.world.bottom_y() + s as i32 * 16;
|
||||
// Without a sculk state in its palette, no block of the section
|
||||
// is one: most sections are passed over without reading a block.
|
||||
if !section.blocks.maybe_has(|v| self.kind_of_state(v) != 0) {
|
||||
@@ -1555,7 +1554,7 @@ impl Game<'_> {
|
||||
.map(|i| [i.position.x, i.position.y, i.position.z])
|
||||
.collect();
|
||||
for at in items {
|
||||
self.step_on_redstone_ore(at, false);
|
||||
self.step_on_redstone_ore(at, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1573,7 +1572,7 @@ impl Game<'_> {
|
||||
if let Some((at, on_ground, health, flying)) = player {
|
||||
let crouch = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
if on_ground && !self.spectator() {
|
||||
self.step_on_redstone_ore(at, crouch);
|
||||
self.step_on_redstone_ore(at, crouch, true);
|
||||
}
|
||||
self.mount_events(Who::Player, self.riding, true);
|
||||
let in_water = self.in_water_at(at);
|
||||
@@ -1594,9 +1593,57 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `RedStoneOreBlock.spawnParticles`: a red dust puff for each face of the
|
||||
/// ore that is not against a solid block. Non-axis coordinates draw from
|
||||
/// the client level random, x then y then z. It is `level.addParticle`, so
|
||||
/// only the local player's own click or step draws it, not a guest's turn.
|
||||
pub(super) fn redstone_ore_particles(&mut self, pos: [i32; 3]) {
|
||||
if self.lan_acting.is_some()
|
||||
|| !matches!(
|
||||
&*self.block_name(pos),
|
||||
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Direction.values(): down, up, north, south, west, east.
|
||||
const FACES: [[i32; 3]; 6] = [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
];
|
||||
for step in FACES {
|
||||
let next = [pos[0] + step[0], pos[1] + step[1], pos[2] + step[2]];
|
||||
let state = self.world.block(next[0], next[1], next[2]);
|
||||
if self.res.facts().get(state).solid_render {
|
||||
continue;
|
||||
}
|
||||
let mut at = [0.0f64; 3];
|
||||
for axis in 0..3 {
|
||||
at[axis] = if step[axis] != 0 {
|
||||
0.5 + 0.5625 * f64::from(step[axis])
|
||||
} else {
|
||||
f64::from(self.particles.level_random().next_float())
|
||||
};
|
||||
}
|
||||
self.particles.add(
|
||||
&horizon_client::particles::ParticleOptions::dust([1.0, 0.0, 0.0], 1.0),
|
||||
[
|
||||
f64::from(pos[0]) + at[0],
|
||||
f64::from(pos[1]) + at[1],
|
||||
f64::from(pos[2]) + at[2],
|
||||
],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `RedStoneOreBlock.stepOn`: standing on the ore lights it, unless the
|
||||
/// entity is stepping carefully (`attack_block` only lights an unlit ore).
|
||||
fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool) {
|
||||
fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool, burst: bool) {
|
||||
if carefully {
|
||||
return;
|
||||
}
|
||||
@@ -1610,6 +1657,10 @@ impl Game<'_> {
|
||||
&*self.block_name(pos),
|
||||
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
|
||||
) {
|
||||
// The dust is the local player's client effect, not a mob's or an item's.
|
||||
if burst {
|
||||
self.redstone_ore_particles(pos);
|
||||
}
|
||||
let _ = self.attack_block(pos);
|
||||
}
|
||||
}
|
||||
@@ -1628,6 +1679,7 @@ impl Game<'_> {
|
||||
) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.redstone_ore_particles(pos);
|
||||
self.attack_block(pos)?;
|
||||
let block_item = held.is_some_and(|s| {
|
||||
self.res
|
||||
@@ -1667,7 +1719,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
for (id, at, on_ground, in_water, species, damaged, vehicle, age) in snapshot {
|
||||
if on_ground {
|
||||
self.step_on_redstone_ore(at, false);
|
||||
self.step_on_redstone_ore(at, false, false);
|
||||
}
|
||||
// A mob first seen already riding raised its event when it spawned.
|
||||
let seen = self.sculk.steps.contains_key(&Who::Mob(id));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
|
||||
//! server list's setting, downloads into `downloads/`, and a resource reload
|
||||
//! with the server's packs on top of the player's own.
|
||||
use super::{Game, Progress};
|
||||
use super::{Game, packs::ReloadKind};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
@@ -89,9 +89,6 @@ pub(super) struct ServerPackState {
|
||||
pub(super) active: Vec<packs::Pack>,
|
||||
/// The download batches' notifiers, oldest first.
|
||||
notifiers: Vec<PackNotifier>,
|
||||
/// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the
|
||||
/// frame draws its progress until `tick_server_packs` applies it.
|
||||
pub(super) reload: Option<PackReload>,
|
||||
}
|
||||
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
|
||||
/// own `SystemToast`.
|
||||
@@ -204,21 +201,6 @@ pub(super) fn saves() -> PathBuf {
|
||||
/// `WorldOpenFlows.WORLD_PACK_ID`: the id of a world's own resource pack.
|
||||
const WORLD_PACK_ID: u128 = 0x640a6a92_b6cb_48a0_b391_831586500359;
|
||||
|
||||
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
|
||||
const RELOAD_LINES: [&str; 4] = [
|
||||
"Preparing resources",
|
||||
"Loading assets",
|
||||
"Loading pack tables",
|
||||
"Applying resources",
|
||||
];
|
||||
/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a
|
||||
/// worker while `progress` feeds the loading screen, then the apply lands
|
||||
/// on the render thread.
|
||||
pub(super) struct PackReload {
|
||||
pub(super) progress: Progress,
|
||||
handle: std::thread::JoinHandle<std::result::Result<super::packs::StagedPack, String>>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `WorldOpenFlows.loadBundledResourcePack`: a world's own
|
||||
/// `resourcepacks/resources.zip` is pushed as a local pack before the world
|
||||
@@ -358,6 +340,13 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.drive_pack_reload();
|
||||
// One reload at a time: the player's own waits under its overlay
|
||||
// before the server's packs can ask for theirs (`ServerPacks` keeps
|
||||
// its own reload from overlapping).
|
||||
let selection_reloading = self
|
||||
.pack_reload
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.kind() == ReloadKind::Selection);
|
||||
let finished = self
|
||||
.server_packs
|
||||
.queue
|
||||
@@ -367,7 +356,7 @@ impl Game<'_> {
|
||||
for batch in finished {
|
||||
self.server_packs.packs.downloaded(batch);
|
||||
}
|
||||
while self.server_packs.packs.wants_tick() {
|
||||
while !selection_reloading && self.server_packs.packs.wants_tick() {
|
||||
let tick = self.server_packs.packs.tick();
|
||||
if !tick.download.is_empty() {
|
||||
self.start_pack_downloads(tick.download);
|
||||
@@ -431,75 +420,14 @@ impl Game<'_> {
|
||||
self.finish_pack_reload(false);
|
||||
return;
|
||||
};
|
||||
self.server_packs.active = server.clone();
|
||||
// The overlay swallows input until the apply (`LoadingOverlay`):
|
||||
// release what is held so nothing keeps acting while it covers the
|
||||
// frame (`KeyMapping.releaseAll` when a screen opens).
|
||||
self.keys.clear();
|
||||
self.physical_keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
self.server_packs.active = server;
|
||||
if let Err(e) = self.spawn_pack_reload(ReloadKind::Server) {
|
||||
log::error!("Horizon: could not start the pack reload worker: {e}");
|
||||
self.finish_pack_reload(false);
|
||||
}
|
||||
let (selected, mods, dev_mods) = (
|
||||
self.settings.resource_packs.clone(),
|
||||
self.settings.mods.enabled.clone(),
|
||||
self.options.dev_mods.clone(),
|
||||
);
|
||||
let (assets, res, context) = (
|
||||
self.pack.assets.clone(),
|
||||
self.res.clone(),
|
||||
self.pack.context.clone(),
|
||||
);
|
||||
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
|
||||
let progress = Progress::with(&RELOAD_LINES);
|
||||
let worker = progress.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("server-pack-reload".into())
|
||||
.spawn(move || {
|
||||
super::packs::stage_pack(
|
||||
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context,
|
||||
unicode, japanese, &worker,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
match handle {
|
||||
Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: could not start the pack reload worker: {e}");
|
||||
self.finish_pack_reload(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PackReloadConfig.Callbacks`: applies the staged reload once its
|
||||
/// worker is done; on failure the packs active before reload again.
|
||||
fn drive_pack_reload(&mut self) {
|
||||
let Some(reload) = &self.server_packs.reload else {
|
||||
return;
|
||||
};
|
||||
if !reload.handle.is_finished() {
|
||||
return;
|
||||
}
|
||||
let reload = self.server_packs.reload.take().unwrap();
|
||||
let staged = reload
|
||||
.handle
|
||||
.join()
|
||||
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
|
||||
let ok = match staged
|
||||
.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: server resource pack reload failed: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
self.finish_pack_reload(ok);
|
||||
}
|
||||
/// `ServerPacks::reloaded` and the recovery reload it can ask for.
|
||||
fn finish_pack_reload(&mut self, ok: bool) {
|
||||
pub(super) fn finish_pack_reload(&mut self, ok: bool) {
|
||||
if let Some(recovery) = self.server_packs.packs.reloaded(ok) {
|
||||
self.start_pack_reload(&recovery);
|
||||
}
|
||||
|
||||
@@ -725,6 +725,7 @@ impl Game<'_> {
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
streaming: Some(&self.streaming),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user